--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 5ddca1c4ecad40d28889d8d058353510689abf0b
Parents : e657ead
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-04T16:41:15-05:00
feat(rrc): improve room message retrieval with pagination support and sequence tracking for messages
Changes
25 files changed, 2621 insertions(+), 79 deletions(-)
Diff
diff --git a/.dockerignore b/.dockerignore
index bf682aa3..9f9621c4 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -79,7 +79,9 @@ Thumbs.db
# Docker files
Dockerfile*
docker-compose*.yml
+docker-compose.override.yml
.dockerignore
+meshchat-config/
# Local storage and runtime data
storage/
@@ -99,11 +101,33 @@ telemetry_test_lxmf/
.env.*.local
.coverage
+coverage/
+coverage-electron/
+.pytest_cache/
+htmlcov/
+.mutmut-cache
+mutmut-stats/
test-results/
playwright-report/
blob-report/
+# Local benchmark / CI artifact downloads
+bench_results.json
+bench_results.txt
+/cache/
+.artifacts/
+
+# Test suites and reports (not used in container image build)
+tests/
+
+# Desktop, CI, and packaging trees not used in Docker image
+electron/
+.github/
+packaging/
+scripts/private/
+scripts/ci/
+
# Host-built micron WASM binaries (Docker/build fetch fresh copies)
meshchatx/src/frontend/public/vendor/micron-parser-go/micron-parser-go.wasm
meshchatx/src/frontend/public/vendor/micron-parser-go/wasm_exec.js
@@ -111,11 +135,6 @@ meshchatx/src/frontend/public/vendor/micron-parser-go/wasm_exec.js
.hypothesis
.hypothesis/
-# Arch Linux packaging artifacts
-/packaging/arch/src/
-/packaging/arch/pkg/
-/packaging/arch/*.pkg.tar.zst
-/packaging/arch/MeshChatX/
-/packaging/arch/reticulum-meshchatx/
+cosign.key
*.exe
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index adbefbb9..9a168ed8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -106,15 +106,29 @@ cosign.key
# Docker (local compose overrides)
docker-compose.override.yml
+meshchat-config/
.coverage
coverage/
coverage-electron/
+.pytest_cache/
+htmlcov/
+.mutmut-cache
+mutmut-stats/
test-results/
playwright-report/
blob-report/
+# Local benchmark / CI artifact downloads
+bench_results.json
+bench_results.txt
+/cache/
+.artifacts/
+
+# Docker runtime smoke (optional local config dir)
+meshchatx-docker-smoke-config/
+
meshchat-config/
# micron-parser-go WASM (fetched at build via scripts/fetch-micron-wasm.mjs)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9dd29e38..87e350d2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,18 +13,33 @@ All notable changes to this project will be documented in this file.
- **Archives / RNCP / messages**: Rich HTML link clicks use a shared navigation policy so **http(s)** links open externally and mesh links stay in-app (same behavior as Nomad Network and the micron editor preview).
- **Android**: External **http(s)** links open in the system browser instead of being silently blocked in the WebView.
- **RNSh**: Session startup prefers **`python -m RNS.Utilities.rnsh.rnsh`** over the pip **`rnsh`** console-script wrapper so sessions work when the wrapper is not executable (common with **`pip install --user`**) or when Landlock denies executing paths outside allowed read roots (e.g. **`~/.local/bin/rnsh`**). Fixes [#36](https://github.com/Quad4-Software/MeshChatX/issues/36).
-- **Interfaces**: The Interfaces page no longer shows all links as **Down** when Reticulum is running normally. **`/api/v1/interface-stats`** now serializes all byte fields (**`network_id`**, interface hashes, IFAC signatures, and related values) instead of failing on unconverted payloads; stats are merged on the frontend by **`interface_name`** as well as **`short_name`**. Fixes false **Down** status when MeshChatX owns its own Reticulum instance (embedded mode); attaching to an already-running **`rnsd`** shared instance was unaffected.
+- **Interfaces**: The Interfaces page no longer shows all links as **Down** when Reticulum is running normally. **`/api/v1/interface-stats`** now serializes all byte fields (**`network_id`**, interface hashes, IFAC signatures, and related values) instead of failing on unconverted payloads. Stats are merged on the frontend by **`interface_name`** as well as **`short_name`**. Fixes false **Down** status when MeshChatX owns its own Reticulum instance (embedded mode). Attaching to an already-running **`rnsd`** shared instance was unaffected.
+- **Messages / stranger banner**: The **Add to Contacts** button on the stranger-protection banner now uses the CSRF-aware **`window.api`** client instead of raw **`fetch`**, so adding a contact from the banner works again. It also checks for an existing contact before creating a duplicate, and shows a success or error toast for the result. Fixes [#38](https://github.com/Quad4-Software/MeshChatX/issues/38).
+- **Android / QR scanning**: Contact, message, and paper-message scanners now use a shared camera helper with higher-resolution constraints, optional continuous autofocus, and a **jsQR** canvas fallback when the WebView **`BarcodeDetector`** API is missing or unreliable. Camera permission is requested at Android startup. Fixes [#41](https://github.com/Quad4-Software/MeshChatX/issues/41).
+- **macOS**: Universal DMG startup no longer fails with **`ImportError: cannot import name '_add_newdoc_ufunc' from 'numpy._core._multiarray_umath'`** or **`incompatible architecture (have 'arm64', need 'x86_64')`** on NumPy. The x64 cx_Freeze slice now installs locked dependencies via **`uv sync --python-platform x86_64-apple-darwin`** (matching the arm64 slice) instead of unpinned **`pip install -e .`**, and the universal merge step refuses to mix mismatched pure-Python files with native extensions under the same package.
+- **UI / sidebars**: Collapsed state for the main navigation sidebar and feature sidebars (Messages, Nomad Network, Relay Chat) now persists in localStorage when switching pages or returning later.
+- **Desktop / crash recovery**: The Electron crash screen now diagnoses database corruption, lists local automatic backups, can restore a backup offline, relaunch with auto-repair or emergency mode, and includes copyable instructions for completely removing MeshChatX and Reticulum data folders.
+- **Database backups**: Creating a manual backup no longer bundles the just-created backup **`.zip`** into its own archive when the file is written inside the identity storage directory.
+- **Notifications**: The notification bell now refreshes immediately when a conversation is marked as read from the conversation view or via bulk mark-as-read in Messages, instead of waiting on the next 5-second poll or the next dropdown open.
+- **Bots**: Error and process log text in the Bots tool no longer picks up a stray line break from the closing **`<pre>`** tag formatting.
### Added
+- **Messages / images**: Conversation images show a hover download control and support right-click **Save image to device** in the full-screen preview, in addition to the existing message context menu action.
+- **Docs**: README troubleshooting section for database corruption, backup restore, and full data removal on Linux, macOS, and Windows.
+
- **Rich HTML links**: Shared **`NomadRichHtmlLinks`** click handler for **v-html** surfaces (Nomad browser, micron editor preview, archives, RNCP, conversation messages). Routes Nomad mesh and LXMF links in-app, opens **http(s)** externally, scrolls in-page anchors, and blocks unsafe **`javascript:`** navigation. Behavior contract tests guard wiring across frontend, Electron, and Android.
- **Location / telemetry**: Settings include a **Disabled** location source so outbound telemetry replies are not sent until the operator opts in. New installs default to **Disabled** instead of browser geolocation.
- **Docs**: **`scripts/sync-meshchatx-docs.js`** copies **`docs/*.md`** into the in-app **`meshchatx-docs`** bundle on **`predev`** and **`prebuild-frontend`**. Adds the **Meta Quest (SideQuest)** installation guide.
+- **Android**: Vendored a patched **bleak** pure-python wheel (unsupported platform-specific backend requirements stripped from its metadata) so **RNode** interfaces can use Bluetooth LE on Android builds.
### Changed
-- **Tests**: Removed obsolete no-op backend tests; strengthened frontend security and UI coverage (**BlockedPage**, **SettingsPage** copy/banished visibility, TGS decode fuzz, **RelayChat** XSS fuzz, **interfaceDiscoveryUtils**, **ArchivesPage**, **NomadRichHtmlLinks**) with behavior assertions instead of always-true checks. Added **`test_interface_stats_endpoint`** for **`/api/v1/interface-stats`** JSON serialization.
+- **Docs**: README clarifies that Linux AppImage and `.deb` packages require **glibc** (not Alpine/PostmarketOS/musl) and documents **`APPIMAGE_EXTRACT_AND_RUN`** for FUSE-less launches. Fixes [#40](https://github.com/Quad4-Software/MeshChatX/issues/40).
+- **Tests**: Removed obsolete no-op backend tests. Strengthened frontend security and UI coverage (**BlockedPage**, **SettingsPage** copy/banished visibility, TGS decode fuzz, **RelayChat** XSS fuzz, **interfaceDiscoveryUtils**, **ArchivesPage**, **NomadRichHtmlLinks**) with behavior assertions instead of always-true checks. Added **`test_interface_stats_endpoint`** for **`/api/v1/interface-stats`** JSON serialization, plus an API client usage guard test that fails if a mutating request bypasses the CSRF-aware **`window.api`** client. New **`task test:quick`** / **`test:quick:be`** / **`test:quick:fe`** commands run a fast backend and frontend regression subset, and CI runs a thin **Playwright** smoke suite (`tests/e2e/smoke.spec.js`) against the backend and Vite dev server.
- **Packaging**: Arch **`PKGBUILD`** installs Python dependencies with **`uv sync`** instead of a bare **`uv pip install`** step.
+- **CI**: GitHub Actions workflows were reorganized around shared composite actions (**`fetch-frontend-artifact`**, **`setup-dev-environment`**, **`setup-python-uv`**), the CodeQL workflow was renamed to **`security.yml`**, and Dependabot now also tracks weekly GitHub Actions updates grouped by publisher.
+- **Dependencies**: Bumped **Vue** to **3.5.39**, **Vuetify** to **3.12.8**, **electron-builder** to **26.15.3**, **Playwright** to **1.61.1**, **prettier** to **3.9.3**, and other minor frontend/backend package versions. Relaxed the **dompurify** version constraint to **`>=3.4.11`** and added an **`app-builder-lib>ajv`** override to keep transitive **ajv** versions compatible.
## [4.7.1] - 2026-06-21
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index b7cf468f..58b9bae2 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -7899,6 +7899,9 @@ class ReticulumMeshChat:
return web.json_response({"error": str(e)}, status=500)
# Reticulum Relay Chat
+ RRC_ROOM_MESSAGES_DEFAULT_LIMIT = 200
+ RRC_ROOM_MESSAGES_MAX_LIMIT = 1000
+
def _rrc_require_manager():
manager = self.rrc_manager
if manager is None:
@@ -8105,12 +8108,28 @@ class ReticulumMeshChat:
return error
room = request.match_info.get("room", "")
try:
- messages = hub.room_messages(room)
+ limit = int(request.query.get("limit", RRC_ROOM_MESSAGES_DEFAULT_LIMIT))
+ except (TypeError, ValueError):
+ limit = RRC_ROOM_MESSAGES_DEFAULT_LIMIT
+ limit = max(1, min(limit, RRC_ROOM_MESSAGES_MAX_LIMIT))
+ before_seq_raw = request.query.get("before_seq")
+ before_seq = None
+ if before_seq_raw not in (None, ""):
+ try:
+ before_seq = int(before_seq_raw)
+ except (TypeError, ValueError):
+ before_seq = None
+ try:
+ messages, has_more = hub.room_messages(
+ room, limit=limit, before_seq=before_seq,
+ )
members = hub.members_dict(room)
except ValueError as e:
return web.json_response({"message": str(e)}, status=400)
manager.set_active(hub, room)
- return web.json_response({"messages": messages, "members": members})
+ return web.json_response(
+ {"messages": messages, "members": members, "has_more": has_more},
+ )
@routes.post("/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages")
async def rrc_hub_send_message(request):
diff --git a/meshchatx/src/backend/rrc/manager.py b/meshchatx/src/backend/rrc/manager.py
index 3ccbe495..999ac978 100644
--- a/meshchatx/src/backend/rrc/manager.py
+++ b/meshchatx/src/backend/rrc/manager.py
@@ -102,6 +102,12 @@ class RRCHub:
self._silent_joins = set()
self._history_write_failed = False
+ self._seq_counter = 0
+
+ def _next_seq(self):
+ with self._lock:
+ self._seq_counter += 1
+ return self._seq_counter
def _log(self, msg, level=None):
if level is None:
@@ -795,6 +801,7 @@ class RRCHub:
continue
if filter_msgs and m.kind in ("system", "notice"):
continue
+ m.seq = self._next_seq()
msgs.append(m)
with self._lock:
self.messages[room] = msgs
@@ -824,6 +831,7 @@ class RRCHub:
def _record_message(self, msg, local=False):
cap = self._per_room_cap()
with self._lock:
+ msg.seq = self._next_seq()
buf = self.messages.setdefault(msg.room or "*", [])
buf.append(msg)
if cap is not None and len(buf) > cap:
@@ -846,6 +854,7 @@ class RRCHub:
msg = proto.RRCMessage("system", room, None, None, text, proto.now_ms())
cap = self._per_room_cap()
with self._lock:
+ msg.seq = self._next_seq()
buf = self.messages.setdefault(room, [])
buf.append(msg)
if cap is not None and len(buf) > cap:
@@ -863,6 +872,7 @@ class RRCHub:
cap = self._per_room_cap()
with self._lock:
+ msg.seq = self._next_seq()
self.notices.append(msg)
if len(self.notices) > 200:
del self.notices[: len(self.notices) - 200]
@@ -1342,9 +1352,22 @@ class RRCHub:
"max_msg_body_bytes": self.max_msg_body_bytes,
}
- def room_messages(self, room):
- """Return serialized messages for a room."""
- return [m.to_dict() for m in self.get_messages(proto.normalize_room(room))]
+ def room_messages(self, room, limit=None, before_seq=None):
+ """Return ``(messages, has_more)`` for a room, newest page last.
+
+ ``before_seq``, when given, restricts results to messages recorded
+ before that sequence number, letting callers page backwards through
+ history. ``limit`` caps how many of the most recent matching messages
+ are returned; ``has_more`` reports whether older messages remain.
+ """
+ msgs = self.get_messages(proto.normalize_room(room))
+ if before_seq is not None:
+ msgs = [m for m in msgs if (m.seq or 0) < before_seq]
+ has_more = False
+ if limit is not None and len(msgs) > limit:
+ has_more = True
+ msgs = msgs[-limit:]
+ return [m.to_dict() for m in msgs], has_more
def members_dict(self, room):
"""Return serialized members for a room."""
diff --git a/meshchatx/src/backend/rrc/protocol.py b/meshchatx/src/backend/rrc/protocol.py
index d1d09016..fc639c3f 100644
--- a/meshchatx/src/backend/rrc/protocol.py
+++ b/meshchatx/src/backend/rrc/protocol.py
@@ -274,6 +274,7 @@ class RRCMessage:
self.text = text
self.ts = ts
self.mention = False
+ self.seq = None
def to_dict(self):
"""Return a JSON-serializable representation of the message."""
@@ -286,4 +287,5 @@ class RRCMessage:
"text": self.text if isinstance(self.text, str) else "",
"ts": int(self.ts) if isinstance(self.ts, int) else 0,
"mention": bool(self.mention),
+ "seq": self.seq,
}
diff --git a/meshchatx/src/frontend/components/relay/RelayChatPage.vue b/meshchatx/src/frontend/components/relay/RelayChatPage.vue
index 0164ba0a..c704cc1e 100644
--- a/meshchatx/src/frontend/components/relay/RelayChatPage.vue
+++ b/meshchatx/src/frontend/components/relay/RelayChatPage.vue
@@ -349,7 +349,9 @@
<div class="flex flex-1 min-w-0 flex-col min-h-0">
<div
ref="messageList"
- class="flex-1 overflow-y-auto custom-scrollbar p-3 space-y-1.5 sm:p-4"
+ class="relative flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4"
+ style="overflow-anchor: none"
+ @scroll="onMessagesScroll"
>
<div
v-if="!selectedRoom"
@@ -358,72 +360,29 @@
<MaterialDesignIcon icon-name="chat-outline" class="size-10 opacity-40" />
{{ $t("relay_chat.no_room_selected") }}
</div>
- <template v-else>
- <template v-for="entry in messageTimeline" :key="timelineEntryKey(entry)">
- <div
- v-if="entry.type === 'dateDivider'"
- class="flex items-center justify-center gap-3 w-full my-3 shrink-0 px-2 select-none"
- role="separator"
- :aria-label="formatDateDividerLabel(entry.dayKey)"
- >
- <span class="h-px w-10 shrink-0 bg-sem-border sm:w-14" aria-hidden="true" />
- <span
- class="max-w-[min(100%,18rem)] text-center text-[11px] font-medium leading-snug tracking-wide text-sem-fg-muted"
- >
- {{ formatDateDividerLabel(entry.dayKey) }}
- </span>
- <span class="h-px w-10 shrink-0 bg-sem-border sm:w-14" aria-hidden="true" />
- </div>
- <template v-else-if="entry.msg">
- <div
- v-if="
- entry.msg.kind === 'system' ||
- entry.msg.kind === 'notice' ||
- entry.msg.kind === 'error'
- "
- class="py-0.5 text-center text-xs italic"
- :class="
- entry.msg.kind === 'error' ? 'text-sem-danger' : 'text-sem-fg-muted'
- "
- :data-msg-key="messageKey(entry.msg)"
- >
- {{ entry.msg.text }}
- </div>
- <div
- v-else-if="entry.msg.kind === 'action'"
- class="rounded-lg px-2 py-1 text-sm italic"
- :class="entry.msg.mention ? 'bg-sem-warning/15' : ''"
- :data-msg-key="messageKey(entry.msg)"
- >
- <span class="mr-1 text-xs text-sem-fg-muted">{{
- formatTime(entry.msg.ts)
- }}</span>
- * {{ displayName(entry.msg) }} {{ entry.msg.text }}
- </div>
- <div
- v-else
- class="rounded-lg px-2 py-1 text-sm"
- :class="
- entry.msg.mention
- ? 'bg-sem-warning/15'
- : 'hover:bg-sem-surface/40 dark:hover:bg-sem-surface/20'
- "
- :data-msg-key="messageKey(entry.msg)"
- @contextmenu.prevent="openMessageContextMenu($event, entry.msg)"
- >
- <span class="mr-1.5 text-xs text-sem-fg-muted">{{
- formatTime(entry.msg.ts)
- }}</span>
- <span class="mr-1.5 font-semibold" :style="nameStyle(entry.msg)"
- >{{ displayName(entry.msg) }}:</span
- >
- <span class="whitespace-pre-wrap break-words">{{
- entry.msg.text
- }}</span>
- </div>
+ <div v-else class="relative min-w-0" :class="useVirtualMessageList ? '' : 'space-y-1.5'">
+ <button
+ v-show="!isLoadingPrevious && hasMorePrevious"
+ type="button"
+ class="absolute top-0 left-1/2 z-20 -translate-x-1/2 flex items-center gap-1.5 rounded-full border border-sem-border bg-sem-canvas/95 px-3 py-1 text-xs font-medium text-sem-fg-secondary shadow-xs backdrop-blur-sm hover:bg-sem-surface/60"
+ @click="loadPreviousMessages"
+ >
+ <MaterialDesignIcon icon-name="arrow-up" class="size-3.5" />
+ {{ $t("relay_chat.load_previous") }}
+ </button>
+ <template v-if="!useVirtualMessageList">
+ <template v-for="entry in messageTimeline" :key="timelineEntryKey(entry)">
+ <RelayMessageEntry :entry="entry" :page="relayChatPageSelf" />
</template>
</template>
- </template>
+ <RelayMessageListVirtual
+ v-else
+ ref="messageListVirtual"
+ :entries="messageTimeline"
+ :get-scroll-element="getMessagesScrollElement"
+ :page="relayChatPageSelf"
+ />
+ </div>
</div>
<form
@@ -1103,12 +1062,15 @@ import { DEFAULT_RRC_HUB_ICON, normalizeMdiIconName } from "../../js/mdiIconName
import { countRelayMentions } from "../../js/relayMentionCount.js";
import { filterRelayMembers, filterRelayMessages } from "../../js/relayMessageSearch.js";
import { buildRelayMessageTimeline, relayMessageKey } from "../../js/relayMessageTimeline.js";
+import { MIN_VIRTUAL_RELAY_ENTRIES } from "./relayMessageListVirtual.js";
import { loadRelayLayout, saveRelayLayout } from "../../js/relayLayoutStore.js";
import { loadFeatureSidebarCollapsed, saveFeatureSidebarCollapsed } from "../../js/browserLayoutStore.js";
import { RELAY_HOST_MODAL_OVERLAY, RELAY_HOST_MODAL_PANEL_COMPACT } from "../../js/relayHostModalClasses.js";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import MdiIconPickerModal from "../MdiIconPickerModal.vue";
import RelayHostModerationPage from "./RelayHostModerationPage.vue";
+import RelayMessageEntry from "./RelayMessageEntry.vue";
+import RelayMessageListVirtual from "./RelayMessageListVirtual.vue";
import ContextMenuPanel from "../contextmenu/ContextMenuPanel.vue";
import ContextMenuItem from "../contextmenu/ContextMenuItem.vue";
import ContextMenuDivider from "../contextmenu/ContextMenuDivider.vue";
@@ -1128,6 +1090,9 @@ const BTN_DANGER_SM =
"inline-flex items-center justify-center rounded-lg border border-sem-border bg-sem-canvas p-1.5 text-sem-fg transition hover:border-sem-danger hover:text-sem-danger hover:bg-sem-danger/10";
const NAME_COLORS = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6", "#3b82f6", "#8b5cf6", "#ec4899"];
+const RELAY_MESSAGES_INITIAL_PAGE_SIZE = 150;
+const RELAY_MESSAGES_PREVIOUS_PAGE_SIZE = 100;
+const LOAD_PREVIOUS_SCROLL_EDGE_PX = 200;
export default {
name: "RelayChatPage",
@@ -1135,6 +1100,8 @@ export default {
MaterialDesignIcon,
MdiIconPickerModal,
RelayHostModerationPage,
+ RelayMessageEntry,
+ RelayMessageListVirtual,
ContextMenuPanel,
ContextMenuItem,
ContextMenuDivider,
@@ -1205,6 +1172,10 @@ export default {
},
messages: [],
members: [],
+ hasMorePrevious: false,
+ isLoadingPrevious: false,
+ loadPreviousInFlight: 0,
+ roomSelectSequence: 0,
composer: "",
sending: false,
joinRoomName: "",
@@ -1269,6 +1240,27 @@ export default {
messageTimeline() {
return buildRelayMessageTimeline(this.messages);
},
+ relayChatPageSelf() {
+ return this;
+ },
+ useVirtualMessageList() {
+ if (this.messageTimeline.length < MIN_VIRTUAL_RELAY_ENTRIES) {
+ return false;
+ }
+ return GlobalState?.config?.message_list_virtualization !== false;
+ },
+ oldestLoadedSeq() {
+ let minSeq = null;
+ for (const msg of this.messages) {
+ if (typeof msg.seq !== "number") {
+ continue;
+ }
+ if (minSeq === null || msg.seq < minSeq) {
+ minSeq = msg.seq;
+ }
+ }
+ return minSeq;
+ },
searchResults() {
return filterRelayMessages(this.messages, this.messageSearch, (msg) => this.displayName(msg));
},
@@ -1829,6 +1821,10 @@ export default {
}
this.showSearch = false;
const key = this.messageKey(msg);
+ if (this.useVirtualMessageList) {
+ nextTick(() => this.$refs.messageListVirtual?.scrollToMessageKey(key));
+ return;
+ }
nextTick(() => {
const el = this.$refs.messageList?.querySelector(`[data-msg-key="${key}"]`);
el?.scrollIntoView({ block: "center", behavior: "smooth" });
@@ -1898,22 +1894,89 @@ export default {
this.selectedHubHash = hubHash;
this.selectedRoom = room;
this.expandedHubs[hubHash] = true;
+ this.hasMorePrevious = false;
+ const seq = ++this.roomSelectSequence;
try {
const response = await window.api.get(
- `/api/v1/rrc/hubs/${hubHash}/rooms/${this.encodeRoom(room)}/messages`
+ `/api/v1/rrc/hubs/${hubHash}/rooms/${this.encodeRoom(room)}/messages`,
+ { params: { limit: RELAY_MESSAGES_INITIAL_PAGE_SIZE } }
);
+ if (seq !== this.roomSelectSequence) {
+ return;
+ }
this.messages = response.data?.messages || [];
this.members = response.data?.members || [];
+ this.hasMorePrevious = Boolean(response.data?.has_more);
this.scrollToBottom();
if (!options.restore) {
await this.fetchHubs();
}
this.persistRelayLayout();
} catch {
+ if (seq !== this.roomSelectSequence) {
+ return;
+ }
this.messages = [];
this.members = [];
+ this.hasMorePrevious = false;
}
},
+ async loadPreviousMessages() {
+ if (this.isLoadingPrevious || !this.hasMorePrevious || !this.selectedHubHash || !this.selectedRoom) {
+ return;
+ }
+ const beforeSeq = this.oldestLoadedSeq;
+ if (beforeSeq === null) {
+ this.hasMorePrevious = false;
+ return;
+ }
+ const seq = this.roomSelectSequence;
+ const hubHash = this.selectedHubHash;
+ const room = this.selectedRoom;
+ this.loadPreviousInFlight += 1;
+ this.isLoadingPrevious = true;
+ try {
+ const response = await window.api.get(
+ `/api/v1/rrc/hubs/${hubHash}/rooms/${this.encodeRoom(room)}/messages`,
+ { params: { limit: RELAY_MESSAGES_PREVIOUS_PAGE_SIZE, before_seq: beforeSeq } }
+ );
+ if (seq !== this.roomSelectSequence || hubHash !== this.selectedHubHash || room !== this.selectedRoom) {
+ return;
+ }
+ const older = response.data?.messages || [];
+ this.hasMorePrevious = Boolean(response.data?.has_more);
+ if (older.length === 0) {
+ return;
+ }
+ const scrollEl = this.useVirtualMessageList ? null : this.$refs.messageList;
+ const prevScrollHeight = scrollEl ? scrollEl.scrollHeight : 0;
+ const prevScrollTop = scrollEl ? scrollEl.scrollTop : 0;
+ this.messages = [...older, ...this.messages];
+ if (scrollEl) {
+ nextTick(() => {
+ const delta = scrollEl.scrollHeight - prevScrollHeight;
+ scrollEl.scrollTop = prevScrollTop + delta;
+ });
+ }
+ } catch {
+ this.hasMorePrevious = false;
+ } finally {
+ this.loadPreviousInFlight = Math.max(0, this.loadPreviousInFlight - 1);
+ this.isLoadingPrevious = this.loadPreviousInFlight > 0;
+ }
+ },
+ onMessagesScroll(event) {
+ const el = event.target;
+ if (!el || this.isLoadingPrevious || !this.hasMorePrevious) {
+ return;
+ }
+ if (el.scrollTop <= LOAD_PREVIOUS_SCROLL_EDGE_PX) {
+ this.loadPreviousMessages();
+ }
+ },
+ getMessagesScrollElement() {
+ return this.$refs.messageList ?? null;
+ },
async refreshMembers() {
if (!this.selectedHubHash || !this.selectedRoom) {
return;
@@ -1929,6 +1992,10 @@ export default {
},
scrollToBottom() {
nextTick(() => {
+ if (this.useVirtualMessageList && this.$refs.messageListVirtual) {
+ this.$refs.messageListVirtual.scrollToBottom();
+ return;
+ }
const el = this.$refs.messageList;
if (el) {
el.scrollTop = el.scrollHeight;
diff --git a/meshchatx/src/frontend/components/relay/RelayMessageEntry.vue b/meshchatx/src/frontend/components/relay/RelayMessageEntry.vue
new file mode 100644
index 00000000..92e837ea
--- /dev/null
+++ b/meshchatx/src/frontend/components/relay/RelayMessageEntry.vue
@@ -0,0 +1,66 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <div
+ v-if="entry.type === 'dateDivider'"
+ class="flex items-center justify-center gap-3 w-full my-3 shrink-0 px-2 select-none"
+ role="separator"
+ :aria-label="page.formatDateDividerLabel(entry.dayKey)"
+ >
+ <span class="h-px w-10 shrink-0 bg-sem-border sm:w-14" aria-hidden="true" />
+ <span
+ class="max-w-[min(100%,18rem)] text-center text-[11px] font-medium leading-snug tracking-wide text-sem-fg-muted"
+ >
+ {{ page.formatDateDividerLabel(entry.dayKey) }}
+ </span>
+ <span class="h-px w-10 shrink-0 bg-sem-border sm:w-14" aria-hidden="true" />
+ </div>
+ <div
+ v-else-if="isSystemLike"
+ class="py-0.5 text-center text-xs italic"
+ :class="entry.msg.kind === 'error' ? 'text-sem-danger' : 'text-sem-fg-muted'"
+ :data-msg-key="page.messageKey(entry.msg)"
+ >
+ {{ entry.msg.text }}
+ </div>
+ <div
+ v-else-if="entry.msg && entry.msg.kind === 'action'"
+ class="rounded-lg px-2 py-1 text-sm italic"
+ :class="entry.msg.mention ? 'bg-sem-warning/15' : ''"
+ :data-msg-key="page.messageKey(entry.msg)"
+ >
+ <span class="mr-1 text-xs text-sem-fg-muted">{{ page.formatTime(entry.msg.ts) }}</span>
+ * {{ page.displayName(entry.msg) }} {{ entry.msg.text }}
+ </div>
+ <div
+ v-else-if="entry.msg"
+ class="rounded-lg px-2 py-1 text-sm"
+ :class="entry.msg.mention ? 'bg-sem-warning/15' : 'hover:bg-sem-surface/40 dark:hover:bg-sem-surface/20'"
+ :data-msg-key="page.messageKey(entry.msg)"
+ @contextmenu.prevent="page.openMessageContextMenu($event, entry.msg)"
+ >
+ <span class="mr-1.5 text-xs text-sem-fg-muted">{{ page.formatTime(entry.msg.ts) }}</span>
+ <span class="mr-1.5 font-semibold" :style="page.nameStyle(entry.msg)">{{ page.displayName(entry.msg) }}:</span>
+ <span class="whitespace-pre-wrap break-words">{{ entry.msg.text }}</span>
+ </div>
+</template>
+
+<script setup>
+import { computed } from "vue";
+
+const props = defineProps({
+ entry: {
+ type: Object,
+ required: true,
+ },
+ page: {
+ type: Object,
+ required: true,
+ },
+});
+
+const isSystemLike = computed(() => {
+ const kind = props.entry?.msg?.kind;
+ return kind === "system" || kind === "notice" || kind === "error";
+});
+</script>
diff --git a/meshchatx/src/frontend/components/relay/RelayMessageListVirtual.vue b/meshchatx/src/frontend/components/relay/RelayMessageListVirtual.vue
new file mode 100644
index 00000000..963e4e47
--- /dev/null
+++ b/meshchatx/src/frontend/components/relay/RelayMessageListVirtual.vue
@@ -0,0 +1,87 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <div class="relative w-full shrink-0" :style="{ height: totalSize + 'px' }">
+ <div
+ v-for="v in virtualItems"
+ :key="entryKey(entries[v.index], v.index)"
+ :ref="measureElement"
+ :data-index="v.index"
+ class="absolute left-0 top-0 w-full box-border px-0 [overflow-anchor:none]"
+ :style="{ transform: `translateY(${v.start}px)` }"
+ >
+ <RelayMessageEntry :entry="entries[v.index]" :page="page" />
+ </div>
+ </div>
+</template>
+
+<script setup>
+import { computed } from "vue";
+import { useVirtualizer } from "@tanstack/vue-virtual";
+import RelayMessageEntry from "./RelayMessageEntry.vue";
+import { estimateRelayEntryHeight, findRelayEntryIndexForMessageKey } from "./relayMessageListVirtual.js";
+
+const props = defineProps({
+ entries: {
+ type: Array,
+ required: true,
+ },
+ getScrollElement: {
+ type: Function,
+ required: true,
+ },
+ page: {
+ type: Object,
+ required: true,
+ },
+});
+
+const virtualizer = useVirtualizer(
+ computed(() => ({
+ count: props.entries.length,
+ getScrollElement: () => props.getScrollElement() ?? null,
+ estimateSize: (index) => estimateRelayEntryHeight(props.entries[index]),
+ overscan: 12,
+ }))
+);
+
+const virtualItems = computed(() => virtualizer.value.getVirtualItems());
+const totalSize = computed(() => virtualizer.value.getTotalSize());
+
+function entryKey(entry, index) {
+ if (!entry) {
+ return index;
+ }
+ if (entry.type === "dateDivider") {
+ return `date-${entry.dayKey}`;
+ }
+ return props.page.messageKey(entry.msg);
+}
+
+function measureElement(el) {
+ if (el) {
+ virtualizer.value.measureElement(el);
+ }
+}
+
+function scrollToMessageKey(key) {
+ const idx = findRelayEntryIndexForMessageKey(props.entries, key, props.page.messageKey);
+ if (idx < 0) {
+ return;
+ }
+ virtualizer.value.scrollToIndex(idx, { align: "center", behavior: "smooth" });
+}
+
+function scrollToBottom() {
+ const n = props.entries.length;
+ if (n === 0) {
+ return;
+ }
+ virtualizer.value.scrollToIndex(n - 1, { align: "end", behavior: "auto" });
+}
+
+defineExpose({
+ scrollToMessageKey,
+ scrollToBottom,
+});
+</script>
diff --git a/meshchatx/src/frontend/components/relay/relayMessageListVirtual.js b/meshchatx/src/frontend/components/relay/relayMessageListVirtual.js
new file mode 100644
index 00000000..7fe4c05d
--- /dev/null
+++ b/meshchatx/src/frontend/components/relay/relayMessageListVirtual.js
@@ -0,0 +1,40 @@
+export const MIN_VIRTUAL_RELAY_ENTRIES = 150;
+
+/**
+ * Initial row height guess before measureElement runs (variable-height rows).
+ * @param {unknown} entry
+ * @returns {number}
+ */
+export function estimateRelayEntryHeight(entry) {
+ if (!entry || typeof entry !== "object") {
+ return 28;
+ }
+ if (entry.type === "dateDivider") {
+ return 44;
+ }
+ const text = typeof entry.msg?.text === "string" ? entry.msg.text : "";
+ let height = 28;
+ if (text) {
+ height += Math.ceil(text.length / 80) * 18;
+ }
+ return height;
+}
+
+/**
+ * @param {unknown[]} entries
+ * @param {string} key
+ * @param {(msg: object) => string} keyFn
+ * @returns {number}
+ */
+export function findRelayEntryIndexForMessageKey(entries, key, keyFn) {
+ if (!entries?.length || !key || typeof keyFn !== "function") {
+ return -1;
+ }
+ for (let i = 0; i < entries.length; i++) {
+ const entry = entries[i];
+ if (entry?.type === "message" && entry.msg && keyFn(entry.msg) === key) {
+ return i;
+ }
+ }
+ return -1;
+}
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 0000752b..8cd50789 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -2264,6 +2264,7 @@
"no_hubs": "No hubs configured yet. Add one to get started.",
"no_rooms": "No rooms joined. Join a room to start chatting.",
"no_room_selected": "Select a room to view messages.",
+ "load_previous": "Load earlier messages",
"rooms": "Rooms",
"members": "Members",
"join_room": "Join Room",
diff --git a/scripts/ci/docker-runtime-smoke.sh b/scripts/ci/docker-runtime-smoke.sh
new file mode 100755
index 00000000..0a2b82a1
--- /dev/null
+++ b/scripts/ci/docker-runtime-smoke.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+# Build the standard Docker image and verify /api/v1/status over HTTPS inside the container.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+cd "$ROOT"
+
+IMAGE="${MESHCHAT_DOCKER_SMOKE_IMAGE:-meshchatx-runtime-smoke:local}"
+CONTAINER="${MESHCHAT_DOCKER_SMOKE_CONTAINER:-meshchatx-runtime-smoke}"
+HOST_PORT="${MESHCHAT_DOCKER_SMOKE_PORT:-18080}"
+CONFIG_DIR="${MESHCHAT_DOCKER_SMOKE_CONFIG:-$(mktemp -d)}"
+TIMEOUT_SEC="${MESHCHAT_DOCKER_SMOKE_TIMEOUT:-180}"
+
+cleanup() {
+ docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
+ if [ "${MESHCHAT_DOCKER_SMOKE_KEEP_CONFIG:-0}" != "1" ] && [ -n "${TMP_CONFIG_CREATED:-}" ]; then
+ rm -rf "$CONFIG_DIR"
+ fi
+}
+trap cleanup EXIT
+
+if [ "$CONFIG_DIR" = "$(mktemp -u)" ] || [ ! -d "$CONFIG_DIR" ]; then
+ :
+fi
+if [ -z "${MESHCHAT_DOCKER_SMOKE_CONFIG:-}" ]; then
+ TMP_CONFIG_CREATED=1
+fi
+
+echo "Building Docker image ${IMAGE}..."
+docker build -f Dockerfile -t "$IMAGE" .
+
+cleanup
+mkdir -p "$CONFIG_DIR"
+
+echo "Starting container on host port ${HOST_PORT}..."
+docker run -d \
+ --name "$CONTAINER" \
+ -p "${HOST_PORT}:8000" \
+ -v "${CONFIG_DIR}:/config" \
+ "$IMAGE" >/dev/null
+
+deadline=$((SECONDS + TIMEOUT_SEC))
+status_ok=0
+while [ "$SECONDS" -lt "$deadline" ]; do
+ if curl -fsSk "https://127.0.0.1:${HOST_PORT}/api/v1/status" >/tmp/meshchatx-docker-status.json 2>/dev/null; then
+ if python3 -c 'import json,sys; d=json.load(open("/tmp/meshchatx-docker-status.json")); sys.exit(0 if d.get("status")=="ok" else 1)'; then
+ status_ok=1
+ break
+ fi
+ fi
+ if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
+ echo "Container exited early. Logs:" >&2
+ docker logs "$CONTAINER" >&2 || true
+ exit 1
+ fi
+ sleep 2
+done
+
+if [ "$status_ok" != "1" ]; then
+ echo "Timed out waiting for https://127.0.0.1:${HOST_PORT}/api/v1/status" >&2
+ docker logs "$CONTAINER" >&2 || true
+ exit 1
+fi
+
+echo "Docker runtime smoke passed: $(cat /tmp/meshchatx-docker-status.json)"
diff --git a/tests/backend/fixtures/ws_message_manifest.json b/tests/backend/fixtures/ws_message_manifest.json
new file mode 100644
index 00000000..6a761f97
--- /dev/null
+++ b/tests/backend/fixtures/ws_message_manifest.json
@@ -0,0 +1,61 @@
+{
+ "client_inbound": [
+ "config.set",
+ "keyboard_shortcuts.delete",
+ "keyboard_shortcuts.get",
+ "keyboard_shortcuts.set",
+ "lxm.generate_paper_uri",
+ "lxm.ingest_uri",
+ "lxmf.forwarding.rule.add",
+ "lxmf.forwarding.rule.delete",
+ "lxmf.forwarding.rule.toggle",
+ "lxmf.forwarding.rules.get",
+ "nomadnet.download.cancel",
+ "nomadnet.file.download",
+ "nomadnet.page.archive.add",
+ "nomadnet.page.archive.flush",
+ "nomadnet.page.archive.load",
+ "nomadnet.page.archives.get",
+ "nomadnet.page.download",
+ "ping"
+ ],
+ "client_direct_responses": [
+ "keyboard_shortcuts",
+ "lxm.generate_paper_uri.result",
+ "lxm.ingest_uri.result",
+ "lxmf.forwarding.rules",
+ "nomadnet.download.cancelled",
+ "nomadnet.file.download",
+ "nomadnet.page.archive.added",
+ "nomadnet.page.archives",
+ "nomadnet.page.download",
+ "pong"
+ ],
+ "server_broadcast": [
+ "announce",
+ "announced",
+ "blocked_destinations",
+ "config",
+ "identity_switched",
+ "lxmf.delivery",
+ "lxmf.telemetry",
+ "lxmf_message_created",
+ "lxmf_message_deleted",
+ "lxmf_message_state_updated",
+ "new_voicemail",
+ "reticulum_reload_status",
+ "rncp.fetch.completed",
+ "rncp.send.completed",
+ "rncp.transfer.progress",
+ "rnsh.output",
+ "rnsh.session.change",
+ "rrc.change",
+ "rrc.message",
+ "rrc.server.change",
+ "telephone_call_ended",
+ "telephone_call_established",
+ "telephone_initiation_status",
+ "telephone_missed_call",
+ "telephone_ringing"
+ ]
+}
diff --git a/tests/backend/http_api_contract_runtime.py b/tests/backend/http_api_contract_runtime.py
new file mode 100644
index 00000000..ca1ef23e
--- /dev/null
+++ b/tests/backend/http_api_contract_runtime.py
@@ -0,0 +1,110 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Shared runtime helpers for HTTP JSON response contract tests."""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass, field
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock
+
+import RNS
+
+from meshchatx.meshchat import ReticulumMeshChat
+
+
+@dataclass(frozen=True)
+class HttpJsonContract:
+ method: str
+ path: str
+ schema: dict
+ match_info: dict[str, str] = field(default_factory=dict)
+ query: dict[str, str] = field(default_factory=dict)
+ allow_statuses: tuple[int, ...] = (200,)
+ alt_schemas: tuple[dict, ...] = ()
+
+
+class _Query:
+ def __init__(self, data: dict[str, str] | None = None):
+ self._data = data or {}
+
+ def get(self, key, default=None):
+ return self._data.get(key, default)
+
+
+def make_contract_app(temp_dir: str, mock_identity: MagicMock) -> ReticulumMeshChat:
+ return ReticulumMeshChat(
+ identity=mock_identity,
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ )
+
+
+def make_minimal_identity() -> MagicMock:
+ mock_id = MagicMock(spec=RNS.Identity)
+ mock_id.hash = b"test_hash_32_bytes_long_01234567"
+ mock_id.hexhash = mock_id.hash.hex()
+ key = b"public_key_bytes_32_chars_long!!"
+ mock_id.get_private_key.return_value = key
+ mock_id.get_public_key.return_value = key
+ return mock_id
+
+
+def bootstrap_contract_app(app: ReticulumMeshChat) -> ReticulumMeshChat:
+ ctx = app.current_context
+ if ctx is None:
+ return app
+ lxmf_dest = MagicMock()
+ lxmf_dest.hexhash = "cc" * 16
+ ctx.local_lxmf_destination = lxmf_dest
+ if getattr(app, "message_router", None) is not None:
+ prop = MagicMock()
+ prop.hexhash = "dd" * 16
+ app.message_router.propagation_destination = prop
+ if getattr(ctx, "telephone_manager", None) is not None:
+ telephone = getattr(ctx.telephone_manager, "telephone", None)
+ if telephone is not None:
+ dest = MagicMock()
+ dest.hexhash = "ee" * 16
+ telephone.destination = dest
+ return app
+
+
+def find_route_handler(app: ReticulumMeshChat, path: str, method: str):
+ for route in app.get_routes():
+ if route.path == path and route.method == method:
+ return route.handler
+ return None
+
+
+def build_request(
+ *,
+ match_info: dict[str, str] | None = None,
+ query: dict[str, str] | None = None,
+) -> MagicMock:
+ request = MagicMock()
+ request.match_info = match_info or {}
+ request.query = _Query(query)
+ return request
+
+
+async def invoke_json_contract(
+ app: ReticulumMeshChat,
+ contract: HttpJsonContract,
+) -> tuple[int, Any]:
+ handler = find_route_handler(app, contract.path, contract.method)
+ if handler is None:
+ raise AssertionError(
+ f"No handler for {contract.method} {contract.path}",
+ )
+ request = build_request(
+ match_info=contract.match_info,
+ query=contract.query,
+ )
+ if contract.method in {"POST", "PATCH", "PUT", "DELETE"}:
+ request.json = AsyncMock(return_value={})
+ response = await handler(request)
+ status = response.status
+ body = json.loads(response.body)
+ return status, body
diff --git a/tests/backend/http_api_response_registry.py b/tests/backend/http_api_response_registry.py
new file mode 100644
index 00000000..839ac0f0
--- /dev/null
+++ b/tests/backend/http_api_response_registry.py
@@ -0,0 +1,425 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Registry of HTTP JSON response contracts for GET /api/v1 routes."""
+
+from __future__ import annotations
+
+import re
+
+from tests.backend.api_json_contract_schemas import (
+ API_V1_APP_INFO_ENVELOPE_SCHEMA,
+ API_V1_STATUS_SCHEMA,
+ AUTH_STATUS_SCHEMA,
+ TELEPHONE_CONTACT_CHECK_SCHEMA,
+ TELEPHONE_CONTACTS_LIST_SCHEMA,
+ TELEPHONE_RINGTONE_STATUS_SCHEMA,
+ TELEPHONE_RINGTONES_LIST_SCHEMA,
+ TELEPHONE_VOICEMAIL_STATUS_SCHEMA,
+ TELEPHONE_VOICEMAILS_ENVELOPE_SCHEMA,
+)
+from tests.backend.http_api_contract_runtime import HttpJsonContract
+from tests.backend.http_api_response_schemas import (
+ ACCESS_ATTEMPTS_SCHEMA,
+ ANNOUNCE_SINGLE_SCHEMA,
+ ANNOUNCES_LIST_SCHEMA,
+ BLACKHOLE_STATUS_SCHEMA,
+ BLOCKED_DESTINATIONS_ENVELOPE_SCHEMA,
+ BOTS_STATUS_SCHEMA,
+ BOTS_SUBPROCESS_LOG_SCHEMA,
+ CHANGELOG_SCHEMA,
+ COMPORTS_ENVELOPE_SCHEMA,
+ COMMUNITY_INTERFACES_SCHEMA,
+ CONFIG_ENVELOPE_SCHEMA,
+ CSRF_ENVELOPE_SCHEMA,
+ DATABASE_BACKUPS_SCHEMA,
+ DATABASE_HEALTH_SCHEMA,
+ DATABASE_SNAPSHOTS_SCHEMA,
+ DEBUG_LOGS_SCHEMA,
+ DESTINATION_DISPLAY_NAME_SCHEMA,
+ DESTINATION_PATH_SCHEMA,
+ DESTINATION_SIGNAL_METRICS_SCHEMA,
+ DESTINATION_STAMP_INFO_SCHEMA,
+ DISCOVERED_INTERFACES_SCHEMA,
+ DISCOVERY_CONFIG_SCHEMA,
+ DOCS_SEARCH_SCHEMA,
+ DOCS_STATUS_SCHEMA,
+ ERROR_ENVELOPE_SCHEMA,
+ FAVOURITES_SCHEMA,
+ GIFS_LIST_SCHEMA,
+ IDENTITIES_LIST_SCHEMA,
+ IDENTITY_BACKUP_BASE32_SCHEMA,
+ INTERFACE_STATS_SCHEMA,
+ INTERFACES_LIST_SCHEMA,
+ LICENSES_ENVELOPE_SCHEMA,
+ LXMF_CONVERSATION_MESSAGES_SCHEMA,
+ LXMF_CONVERSATION_PINS_SCHEMA,
+ LXMF_CONVERSATIONS_SCHEMA,
+ LXMF_FOLDERS_SCHEMA,
+ LXMF_MESSAGE_BLOCKLIST_SCHEMA,
+ LXMF_MESSAGE_URI_SCHEMA,
+ LXMF_PROPAGATION_NODES_SCHEMA,
+ LXMF_PROPAGATION_STATUS_SCHEMA,
+ LXMF_SIEVE_FILTERS_SCHEMA,
+ MAP_DRAWINGS_SCHEMA,
+ MAP_MBTILES_SCHEMA,
+ MAP_OFFLINE_SCHEMA,
+ MEMORY_DIAGNOSTICS_SCHEMA,
+ MESHCHATX_DOCS_CONTENT_SCHEMA,
+ MESHCHATX_DOCS_LIST_SCHEMA,
+ NOMADNET_ARCHIVES_SCHEMA,
+ NOTIFICATIONS_SCHEMA,
+ PAGE_NODE_DETAIL_SCHEMA,
+ PAGE_NODE_FILES_SCHEMA,
+ PAGE_NODE_PAGES_SCHEMA,
+ PAGE_NODES_LIST_SCHEMA,
+ PATH_TABLE_SCHEMA,
+ REPOSITORY_SERVER_LIST_SCHEMA,
+ REPOSITORY_SERVER_STATUS_SCHEMA,
+ RETICULUM_CONFIG_RAW_SCHEMA,
+ RNCP_STATUS_SCHEMA,
+ RNCP_TRANSFER_SCHEMA,
+ RNPATH_RATES_SCHEMA,
+ RNPATH_TABLE_SCHEMA,
+ RNPATH_TRACE_SCHEMA,
+ RNSH_OUTPUT_SCHEMA,
+ RNSH_SESSIONS_SCHEMA,
+ RNSTATUS_SCHEMA,
+ RRC_ACTIVITY_SCHEMA,
+ RRC_HUBS_SCHEMA,
+ RRC_MEMBERS_SCHEMA,
+ RRC_MESSAGES_SCHEMA,
+ RRC_SERVERS_SCHEMA,
+ SERVER_SECURITY_SCHEMA,
+ SPAM_KEYWORDS_SCHEMA,
+ STICKER_PACK_DETAIL_SCHEMA,
+ STICKER_PACKS_LIST_SCHEMA,
+ STICKERS_LIST_SCHEMA,
+ SYSTEM_NETWORK_INTERFACES_SCHEMA,
+ TELEMETRY_HISTORY_SCHEMA,
+ TELEMETRY_LATEST_SCHEMA,
+ TELEMETRY_PEERS_SCHEMA,
+ TELEMETRY_TRACKING_SCHEMA,
+ TELEMETRY_TRUSTED_PEERS_SCHEMA,
+ TELEPHONE_AUDIO_PROFILES_SCHEMA,
+ TELEPHONE_CALL_SCHEMA,
+ TELEPHONE_HISTORY_SCHEMA,
+ TELEPHONE_RECORDINGS_SCHEMA,
+ TELEPHONE_STATUS_SCHEMA,
+ TOOLS_MICRON_PARSER_RELEASE_SCHEMA,
+ TOOLS_RNODE_LATEST_RELEASE_SCHEMA,
+ TRANSLATOR_LANGUAGES_SCHEMA,
+)
+
+_HEX32 = "a" * 32
+_HEX16 = "b" * 16
+_NODE_ID = "1"
+_ROOM = "lobby"
+_HUB_ID = "1"
+_SESSION_ID = "1"
+_TRANSFER_ID = "1"
+_PAGE_NAME = "index.mu"
+
+HTTP_JSON_GET_CONTRACTS: tuple[HttpJsonContract, ...] = (
+ HttpJsonContract("GET", "/api/v1/status", API_V1_STATUS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/app/info", API_V1_APP_INFO_ENVELOPE_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/app/changelog", CHANGELOG_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/auth/status", AUTH_STATUS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/auth/csrf", CSRF_ENVELOPE_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/server/security", SERVER_SECURITY_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/config", CONFIG_ENVELOPE_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/blocked-destinations", BLOCKED_DESTINATIONS_ENVELOPE_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/comports", COMPORTS_ENVELOPE_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/system/network-interfaces", SYSTEM_NETWORK_INTERFACES_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/reticulum/interfaces", INTERFACES_LIST_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/community-interfaces", COMMUNITY_INTERFACES_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/reticulum/discovery", DISCOVERY_CONFIG_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/reticulum/discovered-interfaces", DISCOVERED_INTERFACES_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/reticulum/config/raw", RETICULUM_CONFIG_RAW_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/reticulum/blackhole", BLACKHOLE_STATUS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/interface-stats", INTERFACE_STATS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/path-table", PATH_TABLE_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/licenses", LICENSES_ENVELOPE_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/docs/status", DOCS_STATUS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/docs/search", DOCS_SEARCH_SCHEMA, query={"q": "reticulum"}),
+ HttpJsonContract("GET", "/api/v1/meshchatx-docs/list", MESHCHATX_DOCS_LIST_SCHEMA),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/meshchatx-docs/content",
+ MESHCHATX_DOCS_CONTENT_SCHEMA,
+ query={"path": "meshchatx.md"},
+ ),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/repository-server/status",
+ REPOSITORY_SERVER_STATUS_SCHEMA,
+ allow_statuses=(200, 503),
+ alt_schemas=(ERROR_ENVELOPE_SCHEMA,),
+ ),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/repository-server/list",
+ REPOSITORY_SERVER_LIST_SCHEMA,
+ allow_statuses=(200, 503),
+ alt_schemas=(ERROR_ENVELOPE_SCHEMA,),
+ ),
+ HttpJsonContract("GET", "/api/v1/database/health", DATABASE_HEALTH_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/database/snapshots", DATABASE_SNAPSHOTS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/database/backups", DATABASE_BACKUPS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/debug/logs", DEBUG_LOGS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/debug/access-attempts", ACCESS_ATTEMPTS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/diagnostics/memory", MEMORY_DIAGNOSTICS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/diagnostics/memory/heap", MEMORY_DIAGNOSTICS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/diagnostics/memory/gc", MEMORY_DIAGNOSTICS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/diagnostics/memory/referrers", MEMORY_DIAGNOSTICS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/identities", IDENTITIES_LIST_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/identity/backup/base32", IDENTITY_BACKUP_BASE32_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/announce", ANNOUNCE_SINGLE_SCHEMA, query={"aspect": "lxmf.delivery"}),
+ HttpJsonContract("GET", "/api/v1/announces", ANNOUNCES_LIST_SCHEMA),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/destination/{destination_hash}/path",
+ DESTINATION_PATH_SCHEMA,
+ match_info={"destination_hash": _HEX32},
+ ),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/destination/{destination_hash}/custom-display-name",
+ DESTINATION_DISPLAY_NAME_SCHEMA,
+ match_info={"destination_hash": _HEX32},
+ ),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/destination/{destination_hash}/lxmf-stamp-info",
+ DESTINATION_STAMP_INFO_SCHEMA,
+ match_info={"destination_hash": _HEX32},
+ ),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/destination/{destination_hash}/signal-metrics",
+ DESTINATION_SIGNAL_METRICS_SCHEMA,
+ match_info={"destination_hash": _HEX32},
+ ),
+ HttpJsonContract("GET", "/api/v1/notifications", NOTIFICATIONS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/favourites", FAVOURITES_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/nomadnet/archives", NOMADNET_ARCHIVES_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/page-nodes", PAGE_NODES_LIST_SCHEMA),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/page-nodes/{node_id}",
+ PAGE_NODE_DETAIL_SCHEMA,
+ match_info={"node_id": _NODE_ID},
+ ),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/page-nodes/{node_id}/files",
+ PAGE_NODE_FILES_SCHEMA,
+ match_info={"node_id": _NODE_ID},
+ ),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/page-nodes/{node_id}/pages",
+ PAGE_NODE_PAGES_SCHEMA,
+ match_info={"node_id": _NODE_ID},
+ ),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/page-nodes/{node_id}/pages/{page_name}",
+ PAGE_NODE_DETAIL_SCHEMA,
+ match_info={"node_id": _NODE_ID, "page_name": _PAGE_NAME},
+ ),
+ HttpJsonContract("GET", "/api/v1/lxmf/conversations", LXMF_CONVERSATIONS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/lxmf/folders", LXMF_FOLDERS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/lxmf/conversation-pins", LXMF_CONVERSATION_PINS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/lxmf/sieve-filters", LXMF_SIEVE_FILTERS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/lxmf/message-blocklist", LXMF_MESSAGE_BLOCKLIST_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/lxmf/propagation-nodes", LXMF_PROPAGATION_NODES_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/lxmf/propagation-node/status", LXMF_PROPAGATION_STATUS_SCHEMA),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/lxmf-messages/conversation/{destination_hash}",
+ LXMF_CONVERSATION_MESSAGES_SCHEMA,
+ match_info={"destination_hash": _HEX32},
+ ),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/lxmf-messages/{message_hash}/uri",
+ LXMF_MESSAGE_URI_SCHEMA,
+ match_info={"message_hash": _HEX16},
+ ),
+ HttpJsonContract("GET", "/api/v1/gifs", GIFS_LIST_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/stickers", STICKERS_LIST_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/sticker-packs", STICKER_PACKS_LIST_SCHEMA),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/sticker-packs/{pack_id}",
+ STICKER_PACK_DETAIL_SCHEMA,
+ match_info={"pack_id": _NODE_ID},
+ ),
+ HttpJsonContract("GET", "/api/v1/map/drawings", MAP_DRAWINGS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/map/offline", MAP_OFFLINE_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/map/mbtiles", MAP_MBTILES_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/telemetry/peers", TELEMETRY_PEERS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/telemetry/tracking", TELEMETRY_TRACKING_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/telemetry/trusted-peers", TELEMETRY_TRUSTED_PEERS_SCHEMA),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/telemetry/latest/{destination_hash}",
+ TELEMETRY_LATEST_SCHEMA,
+ match_info={"destination_hash": _HEX32},
+ ),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/telemetry/history/{destination_hash}",
+ TELEMETRY_HISTORY_SCHEMA,
+ match_info={"destination_hash": _HEX32},
+ ),
+ HttpJsonContract("GET", "/api/v1/rrc/hubs", RRC_HUBS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/rrc/servers", RRC_SERVERS_SCHEMA),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages",
+ RRC_MESSAGES_SCHEMA,
+ match_info={"hub_hash": _HEX32, "room": _ROOM},
+ ),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/rrc/servers/{hub_id}/members",
+ RRC_MEMBERS_SCHEMA,
+ match_info={"hub_id": _HUB_ID},
+ ),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/rrc/servers/{hub_id}/activity",
+ RRC_ACTIVITY_SCHEMA,
+ match_info={"hub_id": _HUB_ID},
+ ),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/rrc/servers/{hub_id}/messages",
+ RRC_MESSAGES_SCHEMA,
+ match_info={"hub_id": _HUB_ID},
+ ),
+ HttpJsonContract("GET", "/api/v1/rncp/status", RNCP_STATUS_SCHEMA),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/rncp/transfer/{transfer_id}",
+ RNCP_TRANSFER_SCHEMA,
+ match_info={"transfer_id": _TRANSFER_ID},
+ ),
+ HttpJsonContract("GET", "/api/v1/rnpath/table", RNPATH_TABLE_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/rnpath/rates", RNPATH_RATES_SCHEMA),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/rnpath/trace/{destination_hash}",
+ RNPATH_TRACE_SCHEMA,
+ match_info={"destination_hash": _HEX32},
+ ),
+ HttpJsonContract("GET", "/api/v1/rnsh/sessions", RNSH_SESSIONS_SCHEMA),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/rnsh/sessions/{session_id}/output",
+ RNSH_OUTPUT_SCHEMA,
+ match_info={"session_id": _SESSION_ID},
+ ),
+ HttpJsonContract("GET", "/api/v1/rnstatus", RNSTATUS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/bots/status", BOTS_STATUS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/bots/subprocess-log", BOTS_SUBPROCESS_LOG_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/spam-keywords", SPAM_KEYWORDS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/translator/languages", TRANSLATOR_LANGUAGES_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/tools/micron-parser-go-release", TOOLS_MICRON_PARSER_RELEASE_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/tools/rnode/latest_release", TOOLS_RNODE_LATEST_RELEASE_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/telephone/status", TELEPHONE_STATUS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/telephone/history", TELEPHONE_HISTORY_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/telephone/recordings", TELEPHONE_RECORDINGS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/telephone/audio-profiles", TELEPHONE_AUDIO_PROFILES_SCHEMA),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/telephone/call/{identity_hash}",
+ TELEPHONE_CALL_SCHEMA,
+ match_info={"identity_hash": _HEX32},
+ ),
+ HttpJsonContract("GET", "/api/v1/telephone/voicemail/status", TELEPHONE_VOICEMAIL_STATUS_SCHEMA),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/telephone/voicemails",
+ TELEPHONE_VOICEMAILS_ENVELOPE_SCHEMA,
+ query={"limit": "50", "offset": "0"},
+ ),
+ HttpJsonContract("GET", "/api/v1/telephone/ringtones", TELEPHONE_RINGTONES_LIST_SCHEMA),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/telephone/ringtones/status",
+ TELEPHONE_RINGTONE_STATUS_SCHEMA,
+ query={},
+ ),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/telephone/contacts",
+ TELEPHONE_CONTACTS_LIST_SCHEMA,
+ query={"limit": "100", "offset": "0"},
+ ),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/telephone/contacts/check/{identity_hash}",
+ TELEPHONE_CONTACT_CHECK_SCHEMA,
+ match_info={"identity_hash": _HEX16},
+ ),
+)
+
+HTTP_JSON_GET_CONTRACT_EXCLUDED: tuple[str, ...] = (
+ "/api/v1/database/backup/download",
+ "/api/v1/database/backups/{filename}/download",
+ "/api/v1/database/snapshots/{filename}/download",
+ "/api/v1/docs/export",
+ "/api/v1/docs/export/reticulum",
+ "/api/v1/identity/backup/download",
+ "/api/v1/identities/export-all",
+ "/api/v1/maintenance/messages/export",
+ "/api/v1/bots/export",
+ "/api/v1/gifs/export",
+ "/api/v1/lxmf/folders/export",
+ "/api/v1/lxmf/message-blocklist/export",
+ "/api/v1/stickers/export",
+ "/api/v1/sticker-packs/{pack_id}/export",
+ "/api/v1/telephone/contacts/export",
+ "/api/v1/tools/rnode/download_firmware",
+ "/api/v1/lxmf-messages/attachment/{message_hash}/{attachment_type}",
+ "/api/v1/gifs/{gif_id}/image",
+ "/api/v1/stickers/{sticker_id}/image",
+ "/api/v1/map/tiles/{z}/{x}/{y}",
+ "/api/v1/map/export/{export_id}",
+ "/api/v1/map/export/{export_id}/download",
+ "/api/v1/telephone/recordings/{id}/audio/{side}",
+ "/api/v1/telephone/ringtones/{id}/audio",
+ "/api/v1/telephone/voicemail/greeting/audio",
+ "/api/v1/telephone/voicemails/{id}/audio",
+ "/api/v1/telephone/answer",
+ "/api/v1/telephone/hangup",
+ "/api/v1/telephone/send-to-voicemail",
+ "/api/v1/telephone/mute-transmit",
+ "/api/v1/telephone/mute-receive",
+ "/api/v1/telephone/unmute-transmit",
+ "/api/v1/telephone/unmute-receive",
+ "/api/v1/telephone/switch-audio-profile/{profile_id}",
+ "/api/v1/lxmf/propagation-node/sync",
+ "/api/v1/lxmf/propagation-node/stop-sync",
+ "/api/v1/ping/{destination_hash}/lxmf.delivery",
+)
+
+_HTTP_JSON_GET_EXCLUDED_PATTERNS: tuple[re.Pattern[str], ...] = (
+ re.compile(r"/download$"),
+ re.compile(r"/export$"),
+ re.compile(r"/image$"),
+ re.compile(r"/audio"),
+ re.compile(r"/tiles/"),
+)
+
+
+def is_excluded_json_get_route(path: str) -> bool:
+ if path in HTTP_JSON_GET_CONTRACT_EXCLUDED:
+ return True
+ return any(pattern.search(path) for pattern in _HTTP_JSON_GET_EXCLUDED_PATTERNS)
+
+
+def registered_get_paths() -> set[str]:
+ return {contract.path for contract in HTTP_JSON_GET_CONTRACTS}
diff --git a/tests/backend/http_api_response_schemas.py b/tests/backend/http_api_response_schemas.py
new file mode 100644
index 00000000..a03227a5
--- /dev/null
+++ b/tests/backend/http_api_response_schemas.py
@@ -0,0 +1,681 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Additional JSON Schema definitions for broad HTTP API contract tests."""
+
+from __future__ import annotations
+
+_OBJECT = {"type": "object", "additionalProperties": True}
+_ARRAY = {"type": "array"}
+_STRING = {"type": "string"}
+_INTEGER = {"type": "integer"}
+_BOOLEAN = {"type": "boolean"}
+_NUMBER = {"type": "number"}
+
+ERROR_ENVELOPE_SCHEMA: dict = {
+ "type": "object",
+ "required": ["error"],
+ "properties": {"error": _STRING},
+ "additionalProperties": True,
+}
+
+MESSAGE_ENVELOPE_SCHEMA: dict = {
+ "type": "object",
+ "required": ["message"],
+ "properties": {"message": _STRING},
+ "additionalProperties": True,
+}
+
+CONFIG_ENVELOPE_SCHEMA: dict = {
+ "type": "object",
+ "required": ["config"],
+ "properties": {"config": _OBJECT},
+ "additionalProperties": False,
+}
+
+BLOCKED_DESTINATIONS_ENVELOPE_SCHEMA: dict = {
+ "type": "object",
+ "required": ["blocked_destinations"],
+ "properties": {
+ "blocked_destinations": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["destination_hash", "created_at"],
+ "properties": {
+ "destination_hash": _STRING,
+ "created_at": _STRING,
+ },
+ "additionalProperties": True,
+ },
+ },
+ },
+ "additionalProperties": False,
+}
+
+SERVER_SECURITY_SCHEMA: dict = {
+ "type": "object",
+ "required": [
+ "listen_host",
+ "listen_port",
+ "https_enabled",
+ "is_loopback_bind",
+ "web_ui_ip_allowlist",
+ "privacy_mode_enabled",
+ "auth_enabled",
+ ],
+ "properties": {
+ "listen_host": {"type": ["string", "null"]},
+ "listen_port": {"type": ["integer", "null"]},
+ "https_enabled": _BOOLEAN,
+ "is_loopback_bind": _BOOLEAN,
+ "web_ui_ip_allowlist": _STRING,
+ "privacy_mode_enabled": _BOOLEAN,
+ "auth_enabled": _BOOLEAN,
+ },
+ "additionalProperties": True,
+}
+
+CSRF_ENVELOPE_SCHEMA: dict = {
+ "type": "object",
+ "required": ["csrf_token"],
+ "properties": {"csrf_token": _STRING},
+ "additionalProperties": False,
+}
+
+COMPORTS_ENVELOPE_SCHEMA: dict = {
+ "type": "object",
+ "required": ["comports"],
+ "properties": {"comports": _ARRAY},
+ "additionalProperties": False,
+}
+
+INTERFACES_LIST_SCHEMA: dict = {
+ "type": "object",
+ "required": ["interfaces"],
+ "properties": {"interfaces": _ARRAY},
+ "additionalProperties": True,
+}
+
+COMMUNITY_INTERFACES_SCHEMA: dict = {
+ "type": "object",
+ "required": ["entries"],
+ "properties": {"entries": _ARRAY},
+ "additionalProperties": True,
+}
+
+IDENTITIES_LIST_SCHEMA: dict = {
+ "type": "object",
+ "required": ["identities"],
+ "properties": {"identities": _ARRAY},
+ "additionalProperties": True,
+}
+
+LICENSES_ENVELOPE_SCHEMA: dict = {
+ "type": "object",
+ "required": ["licenses"],
+ "properties": {"licenses": _ARRAY},
+ "additionalProperties": True,
+}
+
+DOCS_STATUS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["status"],
+ "properties": {"status": _STRING},
+ "additionalProperties": True,
+}
+
+DOCS_SEARCH_SCHEMA: dict = {
+ "type": "object",
+ "required": ["results"],
+ "properties": {"results": _ARRAY},
+ "additionalProperties": True,
+}
+
+MESHCHATX_DOCS_LIST_SCHEMA: dict = {
+ "type": "object",
+ "required": ["docs"],
+ "properties": {"docs": _ARRAY},
+ "additionalProperties": True,
+}
+
+MESHCHATX_DOCS_CONTENT_SCHEMA: dict = {
+ "type": "object",
+ "required": ["content"],
+ "properties": {"content": _STRING},
+ "additionalProperties": True,
+}
+
+DATABASE_HEALTH_SCHEMA: dict = {
+ "type": "object",
+ "required": ["healthy"],
+ "properties": {"healthy": _BOOLEAN},
+ "additionalProperties": True,
+}
+
+DATABASE_SNAPSHOTS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["snapshots"],
+ "properties": {"snapshots": _ARRAY},
+ "additionalProperties": True,
+}
+
+DATABASE_BACKUPS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["backups"],
+ "properties": {"backups": _ARRAY},
+ "additionalProperties": True,
+}
+
+DEBUG_LOGS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["logs"],
+ "properties": {"logs": _ARRAY},
+ "additionalProperties": True,
+}
+
+ACCESS_ATTEMPTS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["attempts"],
+ "properties": {"attempts": _ARRAY},
+ "additionalProperties": True,
+}
+
+MEMORY_DIAGNOSTICS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["rss_bytes"],
+ "properties": {"rss_bytes": _INTEGER},
+ "additionalProperties": True,
+}
+
+DISCOVERY_CONFIG_SCHEMA: dict = {
+ "type": "object",
+ "required": [
+ "discover_interfaces",
+ "interface_discovery_sources",
+ "interface_discovery_whitelist",
+ "interface_discovery_blacklist",
+ "required_discovery_value",
+ ],
+ "properties": {
+ "discover_interfaces": {},
+ "interface_discovery_sources": {},
+ "interface_discovery_whitelist": {},
+ "interface_discovery_blacklist": {},
+ "required_discovery_value": {},
+ },
+ "additionalProperties": True,
+}
+
+DISCOVERED_INTERFACES_SCHEMA: dict = {
+ "type": "object",
+ "required": ["interfaces"],
+ "properties": {"interfaces": _ARRAY},
+ "additionalProperties": True,
+}
+
+RETICULUM_CONFIG_RAW_SCHEMA: dict = {
+ "type": "object",
+ "required": ["config"],
+ "properties": {"config": _STRING},
+ "additionalProperties": False,
+}
+
+BLACKHOLE_STATUS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["enabled"],
+ "properties": {"enabled": _BOOLEAN},
+ "additionalProperties": True,
+}
+
+INTERFACE_STATS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["interfaces"],
+ "properties": {"interfaces": _ARRAY},
+ "additionalProperties": True,
+}
+
+PATH_TABLE_SCHEMA: dict = {
+ "type": "object",
+ "required": ["paths"],
+ "properties": {"paths": _ARRAY},
+ "additionalProperties": True,
+}
+
+LXMF_CONVERSATIONS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["conversations"],
+ "properties": {"conversations": _ARRAY},
+ "additionalProperties": True,
+}
+
+LXMF_FOLDERS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["folders"],
+ "properties": {"folders": _ARRAY},
+ "additionalProperties": True,
+}
+
+LXMF_CONVERSATION_PINS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["pins"],
+ "properties": {"pins": _ARRAY},
+ "additionalProperties": True,
+}
+
+LXMF_SIEVE_FILTERS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["filters"],
+ "properties": {"filters": _ARRAY},
+ "additionalProperties": True,
+}
+
+LXMF_MESSAGE_BLOCKLIST_SCHEMA: dict = {
+ "type": "object",
+ "required": ["entries"],
+ "properties": {"entries": _ARRAY},
+ "additionalProperties": True,
+}
+
+LXMF_PROPAGATION_NODES_SCHEMA: dict = {
+ "type": "object",
+ "required": ["nodes"],
+ "properties": {"nodes": _ARRAY},
+ "additionalProperties": True,
+}
+
+LXMF_PROPAGATION_STATUS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["status"],
+ "properties": {"status": _STRING},
+ "additionalProperties": True,
+}
+
+NOTIFICATIONS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["notifications"],
+ "properties": {"notifications": _ARRAY},
+ "additionalProperties": True,
+}
+
+FAVOURITES_SCHEMA: dict = {
+ "type": "object",
+ "required": ["favourites"],
+ "properties": {"favourites": _ARRAY},
+ "additionalProperties": True,
+}
+
+PAGE_NODES_LIST_SCHEMA: dict = {
+ "type": "object",
+ "required": ["nodes"],
+ "properties": {"nodes": _ARRAY},
+ "additionalProperties": True,
+}
+
+PAGE_NODE_DETAIL_SCHEMA: dict = {
+ "type": "object",
+ "required": ["node"],
+ "properties": {"node": _OBJECT},
+ "additionalProperties": True,
+}
+
+PAGE_NODE_FILES_SCHEMA: dict = {
+ "type": "object",
+ "required": ["files"],
+ "properties": {"files": _ARRAY},
+ "additionalProperties": True,
+}
+
+PAGE_NODE_PAGES_SCHEMA: dict = {
+ "type": "object",
+ "required": ["pages"],
+ "properties": {"pages": _ARRAY},
+ "additionalProperties": True,
+}
+
+NOMADNET_ARCHIVES_SCHEMA: dict = {
+ "type": "object",
+ "required": ["archives"],
+ "properties": {"archives": _ARRAY},
+ "additionalProperties": True,
+}
+
+GIFS_LIST_SCHEMA: dict = {
+ "type": "object",
+ "required": ["gifs"],
+ "properties": {"gifs": _ARRAY},
+ "additionalProperties": True,
+}
+
+STICKERS_LIST_SCHEMA: dict = {
+ "type": "object",
+ "required": ["stickers"],
+ "properties": {"stickers": _ARRAY},
+ "additionalProperties": True,
+}
+
+STICKER_PACKS_LIST_SCHEMA: dict = {
+ "type": "object",
+ "required": ["packs"],
+ "properties": {"packs": _ARRAY},
+ "additionalProperties": True,
+}
+
+STICKER_PACK_DETAIL_SCHEMA: dict = {
+ "type": "object",
+ "required": ["pack"],
+ "properties": {"pack": _OBJECT},
+ "additionalProperties": True,
+}
+
+MAP_DRAWINGS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["drawings"],
+ "properties": {"drawings": _ARRAY},
+ "additionalProperties": True,
+}
+
+MAP_OFFLINE_SCHEMA: dict = {
+ "type": "object",
+ "required": ["tiles"],
+ "properties": {"tiles": _ARRAY},
+ "additionalProperties": True,
+}
+
+MAP_MBTILES_SCHEMA: dict = {
+ "type": "object",
+ "required": ["mbtiles"],
+ "properties": {"mbtiles": _ARRAY},
+ "additionalProperties": True,
+}
+
+TELEMETRY_PEERS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["peers"],
+ "properties": {"peers": _ARRAY},
+ "additionalProperties": True,
+}
+
+TELEMETRY_TRACKING_SCHEMA: dict = {
+ "type": "object",
+ "required": ["tracking"],
+ "properties": {"tracking": _ARRAY},
+ "additionalProperties": True,
+}
+
+TELEMETRY_TRUSTED_PEERS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["trusted_peers"],
+ "properties": {"trusted_peers": _ARRAY},
+ "additionalProperties": True,
+}
+
+TELEMETRY_LATEST_SCHEMA: dict = {
+ "type": "object",
+ "required": ["telemetry"],
+ "properties": {"telemetry": {"type": ["object", "null"]}},
+ "additionalProperties": True,
+}
+
+TELEMETRY_HISTORY_SCHEMA: dict = {
+ "type": "object",
+ "required": ["history"],
+ "properties": {"history": _ARRAY},
+ "additionalProperties": True,
+}
+
+RRC_HUBS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["hubs"],
+ "properties": {"hubs": _ARRAY},
+ "additionalProperties": True,
+}
+
+RRC_SERVERS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["servers"],
+ "properties": {"servers": _ARRAY},
+ "additionalProperties": True,
+}
+
+RRC_MESSAGES_SCHEMA: dict = {
+ "type": "object",
+ "required": ["messages"],
+ "properties": {"messages": _ARRAY},
+ "additionalProperties": True,
+}
+
+RRC_MEMBERS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["members"],
+ "properties": {"members": _ARRAY},
+ "additionalProperties": True,
+}
+
+RRC_ACTIVITY_SCHEMA: dict = {
+ "type": "object",
+ "required": ["activity"],
+ "properties": {"activity": _ARRAY},
+ "additionalProperties": True,
+}
+
+RNCP_STATUS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["status"],
+ "properties": {"status": _STRING},
+ "additionalProperties": True,
+}
+
+RNCP_TRANSFER_SCHEMA: dict = {
+ "type": "object",
+ "required": ["transfer"],
+ "properties": {"transfer": {"type": ["object", "null"]}},
+ "additionalProperties": True,
+}
+
+RNPATH_TABLE_SCHEMA: dict = {
+ "type": "object",
+ "required": ["table"],
+ "properties": {"table": _ARRAY},
+ "additionalProperties": True,
+}
+
+RNPATH_RATES_SCHEMA: dict = {
+ "type": "object",
+ "required": ["rates"],
+ "properties": {"rates": _ARRAY},
+ "additionalProperties": True,
+}
+
+RNPATH_TRACE_SCHEMA: dict = {
+ "type": "object",
+ "required": ["trace"],
+ "properties": {"trace": _ARRAY},
+ "additionalProperties": True,
+}
+
+RNSH_SESSIONS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["sessions"],
+ "properties": {"sessions": _ARRAY},
+ "additionalProperties": True,
+}
+
+RNSH_OUTPUT_SCHEMA: dict = {
+ "type": "object",
+ "required": ["output"],
+ "properties": {"output": _STRING},
+ "additionalProperties": True,
+}
+
+RNSTATUS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["interfaces"],
+ "properties": {"interfaces": _ARRAY},
+ "additionalProperties": True,
+}
+
+BOTS_STATUS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["bots"],
+ "properties": {"bots": _ARRAY},
+ "additionalProperties": True,
+}
+
+BOTS_SUBPROCESS_LOG_SCHEMA: dict = {
+ "type": "object",
+ "required": ["log"],
+ "properties": {"log": _STRING},
+ "additionalProperties": True,
+}
+
+SPAM_KEYWORDS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["keywords"],
+ "properties": {"keywords": _ARRAY},
+ "additionalProperties": True,
+}
+
+SYSTEM_NETWORK_INTERFACES_SCHEMA: dict = {
+ "type": "object",
+ "required": ["interfaces"],
+ "properties": {"interfaces": _ARRAY},
+ "additionalProperties": True,
+}
+
+TRANSLATOR_LANGUAGES_SCHEMA: dict = {
+ "type": "object",
+ "required": ["languages"],
+ "properties": {"languages": _ARRAY},
+ "additionalProperties": True,
+}
+
+TOOLS_MICRON_PARSER_RELEASE_SCHEMA: dict = {
+ "type": "object",
+ "required": ["release"],
+ "properties": {"release": _STRING},
+ "additionalProperties": True,
+}
+
+TOOLS_RNODE_LATEST_RELEASE_SCHEMA: dict = {
+ "type": "object",
+ "required": ["release"],
+ "properties": {"release": _OBJECT},
+ "additionalProperties": True,
+}
+
+REPOSITORY_SERVER_STATUS_SCHEMA: dict = {
+ "type": "object",
+ "additionalProperties": True,
+}
+
+REPOSITORY_SERVER_LIST_SCHEMA: dict = {
+ "type": "object",
+ "required": ["entries"],
+ "properties": {"entries": _ARRAY},
+ "additionalProperties": True,
+}
+
+ANNOUNCE_SINGLE_SCHEMA: dict = {
+ "type": "object",
+ "required": ["announce"],
+ "properties": {"announce": {"type": ["object", "null"]}},
+ "additionalProperties": True,
+}
+
+ANNOUNCES_LIST_SCHEMA: dict = {
+ "type": "object",
+ "required": ["announces"],
+ "properties": {"announces": _ARRAY},
+ "additionalProperties": True,
+}
+
+DESTINATION_PATH_SCHEMA: dict = {
+ "type": "object",
+ "required": ["path"],
+ "properties": {"path": {"type": ["object", "null"]}},
+ "additionalProperties": True,
+}
+
+DESTINATION_DISPLAY_NAME_SCHEMA: dict = {
+ "type": "object",
+ "required": ["custom_display_name"],
+ "properties": {"custom_display_name": {"type": ["string", "null"]}},
+ "additionalProperties": True,
+}
+
+DESTINATION_STAMP_INFO_SCHEMA: dict = {
+ "type": "object",
+ "required": ["stamp_info"],
+ "properties": {"stamp_info": {"type": ["object", "null"]}},
+ "additionalProperties": True,
+}
+
+DESTINATION_SIGNAL_METRICS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["metrics"],
+ "properties": {"metrics": {"type": ["object", "null"]}},
+ "additionalProperties": True,
+}
+
+LXMF_CONVERSATION_MESSAGES_SCHEMA: dict = {
+ "type": "object",
+ "required": ["messages"],
+ "properties": {"messages": _ARRAY},
+ "additionalProperties": True,
+}
+
+LXMF_MESSAGE_URI_SCHEMA: dict = {
+ "type": "object",
+ "required": ["uri"],
+ "properties": {"uri": _STRING},
+ "additionalProperties": True,
+}
+
+IDENTITY_BACKUP_BASE32_SCHEMA: dict = {
+ "type": "object",
+ "required": ["base32"],
+ "properties": {"base32": _STRING},
+ "additionalProperties": True,
+}
+
+CHANGELOG_SCHEMA: dict = {
+ "type": "object",
+ "required": ["changelog"],
+ "properties": {"changelog": _STRING},
+ "additionalProperties": True,
+}
+
+TELEPHONE_STATUS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["status"],
+ "properties": {"status": _INTEGER},
+ "additionalProperties": True,
+}
+
+TELEPHONE_HISTORY_SCHEMA: dict = {
+ "type": "object",
+ "required": ["history"],
+ "properties": {"history": _ARRAY},
+ "additionalProperties": True,
+}
+
+TELEPHONE_RECORDINGS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["recordings"],
+ "properties": {"recordings": _ARRAY},
+ "additionalProperties": True,
+}
+
+TELEPHONE_AUDIO_PROFILES_SCHEMA: dict = {
+ "type": "object",
+ "required": ["profiles"],
+ "properties": {"profiles": _ARRAY},
+ "additionalProperties": True,
+}
+
+TELEPHONE_CALL_SCHEMA: dict = {
+ "type": "object",
+ "required": ["call"],
+ "properties": {"call": {"type": ["object", "null"]}},
+ "additionalProperties": True,
+}
diff --git a/tests/backend/test_docker_runtime_smoke.py b/tests/backend/test_docker_runtime_smoke.py
new file mode 100644
index 00000000..5e204a1d
--- /dev/null
+++ b/tests/backend/test_docker_runtime_smoke.py
@@ -0,0 +1,71 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Docker image runtime smoke test (build + /api/v1/status)."""
+
+from __future__ import annotations
+
+import json
+import os
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+_REPO_ROOT = Path(__file__).resolve().parents[2]
+_SMOKE_SCRIPT = _REPO_ROOT / "scripts" / "ci" / "docker-runtime-smoke.sh"
+
+
+def _docker_available() -> bool:
+ if shutil.which("docker") is None:
+ return False
+ try:
+ subprocess.run(
+ ["docker", "info"],
+ check=True,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ timeout=30,
+ )
+ except (subprocess.SubprocessError, OSError):
+ return False
+ return True
+
+
+@pytest.mark.integration
+@pytest.mark.skipif(
+ os.environ.get("MESHCHAT_DOCKER_SMOKE") != "1",
+ reason="Set MESHCHAT_DOCKER_SMOKE=1 to run Docker runtime smoke (build + /api/v1/status)",
+)
+@pytest.mark.skipif(not _docker_available(), reason="Docker is not available")
+def test_docker_image_serves_status():
+ env = os.environ.copy()
+ env.setdefault("MESHCHAT_DOCKER_SMOKE_TIMEOUT", "240")
+ result = subprocess.run(
+ ["bash", str(_SMOKE_SCRIPT)],
+ cwd=_REPO_ROOT,
+ env=env,
+ capture_output=True,
+ text=True,
+ timeout=int(env["MESHCHAT_DOCKER_SMOKE_TIMEOUT"]) + 120,
+ check=False,
+ )
+ assert result.returncode == 0, (
+ f"Docker runtime smoke failed (exit {result.returncode})\n"
+ f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
+ )
+ assert '"status": "ok"' in result.stdout or '"status":"ok"' in result.stdout
+
+
+@pytest.mark.integration
+@pytest.mark.skipif(
+ os.environ.get("MESHCHAT_DOCKER_SMOKE") != "1",
+ reason="Set MESHCHAT_DOCKER_SMOKE=1 to run Docker runtime smoke",
+)
+@pytest.mark.skipif(not _docker_available(), reason="Docker is not available")
+def test_docker_status_json_shape():
+ """Lightweight follow-up: status body matches minimal contract when smoke script left output."""
+ sample = {"status": "ok", "listen_port": 8000, "https_enabled": True}
+ from tests.backend.api_json_contract_schemas import API_V1_STATUS_SCHEMA, assert_matches_schema
+
+ assert_matches_schema(sample, API_V1_STATUS_SCHEMA)
diff --git a/tests/backend/test_http_api_json_contracts_broad.py b/tests/backend/test_http_api_json_contracts_broad.py
new file mode 100644
index 00000000..e3d74be0
--- /dev/null
+++ b/tests/backend/test_http_api_json_contracts_broad.py
@@ -0,0 +1,124 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Broad JSON Schema contract tests for GET /api/v1 HTTP handlers."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from tests.backend.api_json_contract_schemas import assert_matches_schema
+from tests.backend.http_api_contract_helpers import load_route_fixture
+from tests.backend.http_api_contract_runtime import (
+ HttpJsonContract,
+ bootstrap_contract_app,
+ invoke_json_contract,
+ make_contract_app,
+ make_minimal_identity,
+)
+from tests.backend.http_api_response_registry import (
+ HTTP_JSON_GET_CONTRACTS,
+ is_excluded_json_get_route,
+ registered_get_paths,
+)
+
+_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "http_api_routes.json"
+
+
+@pytest.fixture(scope="module")
+def temp_dir():
+ import shutil
+ import tempfile
+
+ dir_path = tempfile.mkdtemp()
+ yield dir_path
+ shutil.rmtree(dir_path)
+
+
+@pytest.fixture(scope="module")
+def mock_rns_minimal():
+ with (
+ patch("RNS.Reticulum") as mock_rns,
+ patch("RNS.Transport"),
+ patch("LXMF.LXMRouter"),
+ patch("meshchatx.meshchat.get_file_path", return_value="/tmp/mock_path"),
+ ):
+ mock_rns_instance = mock_rns.return_value
+ mock_rns_instance.configpath = "/tmp/mock_config"
+ mock_rns_instance.is_connected_to_shared_instance = False
+ mock_rns_instance.transport_enabled.return_value = True
+ yield make_minimal_identity()
+
+
+@pytest.fixture(scope="module")
+def contract_app(mock_rns_minimal, temp_dir):
+ with (
+ patch("meshchatx.meshchat.generate_ssl_certificate"),
+ patch("psutil.Process") as mock_process,
+ patch("psutil.net_io_counters") as mock_net_io,
+ patch("importlib.metadata.version", return_value="1.2.3"),
+ patch("meshchatx.meshchat.LXST") as mock_lxst,
+ patch("threading.Thread"),
+ ):
+ mock_lxst.__version__ = "1.2.3"
+ mock_proc_instance = mock_process.return_value
+ mock_proc_instance.memory_info.return_value.rss = 1024
+ mock_proc_instance.memory_info.return_value.vms = 2048
+ mock_proc_instance.net_connections.return_value = []
+ mock_net_instance = mock_net_io.return_value
+ mock_net_instance.bytes_sent = 0
+ mock_net_instance.bytes_recv = 0
+ mock_net_instance.packets_sent = 0
+ mock_net_instance.packets_recv = 0
+ app = bootstrap_contract_app(make_contract_app(temp_dir, mock_rns_minimal))
+ yield app
+
+
+def _validate_body(contract: HttpJsonContract, status: int, body) -> None:
+ schemas = (contract.schema, *contract.alt_schemas)
+ if status in contract.allow_statuses:
+ for schema in schemas:
+ try:
+ assert_matches_schema(body, schema)
+ return
+ except Exception:
+ continue
+ raise AssertionError(
+ f"{contract.method} {contract.path} status {status} body did not match "
+ f"any allowed schema: {json.dumps(body)[:500]}",
+ )
+ raise AssertionError(
+ f"{contract.method} {contract.path} returned unexpected status {status}: "
+ f"{json.dumps(body)[:500]}",
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "contract",
+ HTTP_JSON_GET_CONTRACTS,
+ ids=lambda c: f"{c.method} {c.path}",
+)
+async def test_get_json_response_matches_schema(contract_app, contract: HttpJsonContract):
+ status, body = await invoke_json_contract(contract_app, contract)
+ _validate_body(contract, status, body)
+
+
+def test_every_json_get_route_is_registered_or_excluded():
+ routes = load_route_fixture(_FIXTURE)
+ get_api = [r["path"] for r in routes if r["method"] == "GET" and r["path"].startswith("/api/v1")]
+ registered = registered_get_paths()
+ uncovered = []
+ for path in get_api:
+ if path in registered:
+ continue
+ if is_excluded_json_get_route(path):
+ continue
+ uncovered.append(path)
+ assert not uncovered, (
+ "GET /api/v1 routes missing from HTTP JSON contract registry "
+ f"(add schema or exclude): {uncovered}"
+ )
diff --git a/tests/backend/test_rrc_protocol.py b/tests/backend/test_rrc_protocol.py
index d19ba5bd..93b92a83 100644
--- a/tests/backend/test_rrc_protocol.py
+++ b/tests/backend/test_rrc_protocol.py
@@ -87,6 +87,7 @@ def test_parse_room_list_notice():
def test_message_to_dict():
msg = proto.RRCMessage("msg", "lobby", b"\xaa\xbb", "bob", "hi", 123)
msg.mention = True
+ msg.seq = 7
assert msg.to_dict() == {
"kind": "msg",
"room": "lobby",
@@ -95,6 +96,7 @@ def test_message_to_dict():
"text": "hi",
"ts": 123,
"mention": True,
+ "seq": 7,
}
diff --git a/tests/backend/test_ws_json_contracts.py b/tests/backend/test_ws_json_contracts.py
new file mode 100644
index 00000000..ab055c62
--- /dev/null
+++ b/tests/backend/test_ws_json_contracts.py
@@ -0,0 +1,99 @@
+# SPDX-License-Identifier: 0BSD
+
+"""WebSocket JSON message contract tests."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from meshchatx.meshchat import ReticulumMeshChat
+from tests.backend.ws_contract_helpers import (
+ extract_client_direct_response_types,
+ extract_client_inbound_types,
+ extract_server_broadcast_types,
+ load_ws_manifest,
+ write_ws_manifest,
+)
+from tests.backend.ws_json_contract_schemas import (
+ WS_MESSAGE_SAMPLES,
+ WS_MESSAGE_SCHEMAS,
+ all_ws_message_types,
+ assert_ws_message_matches_schema,
+)
+
+_REPO_ROOT = Path(__file__).resolve().parents[2]
+_MESHCHAT_PY = _REPO_ROOT / "meshchatx" / "meshchat.py"
+_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "ws_message_manifest.json"
+
+
+def _all_manifest_types(manifest: dict[str, list[str]]) -> set[str]:
+ out: set[str] = set()
+ for key in ("client_inbound", "client_direct_responses", "server_broadcast"):
+ out.update(manifest.get(key, []))
+ return out
+
+
+def test_ws_message_manifest_matches_meshchat():
+ live = {
+ "client_inbound": extract_client_inbound_types(_MESHCHAT_PY),
+ "client_direct_responses": extract_client_direct_response_types(_MESHCHAT_PY),
+ "server_broadcast": extract_server_broadcast_types(_MESHCHAT_PY),
+ }
+ if __import__("os").environ.get("UPDATE_WS_MESSAGE_MANIFEST") == "1":
+ write_ws_manifest(_FIXTURE, live)
+ pytest.skip(
+ "UPDATE_WS_MESSAGE_MANIFEST=1: fixture updated; re-run without the env var",
+ )
+ expected = load_ws_manifest(_FIXTURE)
+ assert live == expected, (
+ "WebSocket message manifest drifted. Run: "
+ "UPDATE_WS_MESSAGE_MANIFEST=1 uv run pytest "
+ "tests/backend/test_ws_json_contracts.py -k manifest_matches_meshchat"
+ )
+
+
+def test_every_manifest_type_has_schema():
+ manifest = load_ws_manifest(_FIXTURE)
+ missing = sorted(_all_manifest_types(manifest) - all_ws_message_types())
+ assert not missing, f"Missing WebSocket schemas for: {missing}"
+
+
+def test_every_schema_has_sample_payload():
+ missing = sorted(all_ws_message_types() - set(WS_MESSAGE_SAMPLES))
+ assert not missing, f"Missing WebSocket sample payloads for: {missing}"
+
+
+@pytest.mark.parametrize("msg_type", sorted(WS_MESSAGE_SCHEMAS))
+def test_ws_schema_accepts_documented_sample(msg_type: str):
+ sample = WS_MESSAGE_SAMPLES[msg_type]
+ assert sample["type"] == msg_type
+ assert_ws_message_matches_schema(sample)
+
+
+@pytest.mark.asyncio
+async def test_ws_ping_emits_pong_contract():
+ app = ReticulumMeshChat.__new__(ReticulumMeshChat)
+ client = MagicMock()
+ sent: list[str] = []
+
+ async def capture_send(payload: str):
+ sent.append(payload)
+
+ client.send_str = AsyncMock(side_effect=capture_send)
+
+ with patch("meshchatx.meshchat.AsyncUtils") as mock_async_utils:
+ def run_async(coro):
+ import asyncio
+
+ if asyncio.iscoroutine(coro):
+ asyncio.get_event_loop().create_task(coro)
+
+ mock_async_utils.run_async.side_effect = run_async
+ await app.on_websocket_data_received(client, {"type": "ping"})
+ for payload in sent:
+ assert_ws_message_matches_schema(json.loads(payload))
+ assert json.loads(payload)["type"] == "pong"
diff --git a/tests/backend/ws_contract_helpers.py b/tests/backend/ws_contract_helpers.py
new file mode 100644
index 00000000..d3c1ad03
--- /dev/null
+++ b/tests/backend/ws_contract_helpers.py
@@ -0,0 +1,94 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Helpers for WebSocket JSON message contract checks."""
+
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+
+_CLIENT_HANDLER_RE = re.compile(
+ r"(?:if|elif)\s+_type\s*==\s*[\"']([^\"']+)[\"']",
+)
+_WS_SEND_STR_RE = re.compile(
+ r"client\.send_str\s*\(\s*json\.dumps\s*\(\s*(\{[\s\S]*?\})\s*,?\s*\)",
+)
+_WS_TYPE_LITERAL_RE = re.compile(
+ r"[\"']type[\"']\s*:\s*[\"']([^\"']+)[\"']",
+)
+_BROADCAST_CALL_RE = re.compile(
+ r"(?:websocket_broadcast|_broadcast_websocket_message)\s*\(",
+)
+
+
+def extract_client_inbound_types(meshchat_py: Path) -> list[str]:
+ text = meshchat_py.read_text(encoding="utf-8")
+ start = text.find("async def on_websocket_data_received")
+ if start < 0:
+ return []
+ end = text.find("\n async def ", start + 1)
+ if end < 0:
+ end = text.find("\n def ", start + 1)
+ block = text[start:end] if end > start else text[start:]
+ types = _CLIENT_HANDLER_RE.findall(block)
+ return sorted(set(types))
+
+
+def _extract_type_literals_from_dict_literal(blob: str) -> str | None:
+ match = re.search(r"[\"']type[\"']\s*:\s*[\"']([^\"']+)[\"']", blob)
+ return match.group(1) if match else None
+
+
+def extract_client_direct_response_types(meshchat_py: Path) -> list[str]:
+ text = meshchat_py.read_text(encoding="utf-8")
+ start = text.find("async def on_websocket_data_received")
+ if start < 0:
+ return []
+ end = text.find("\n async def ", start + 1)
+ block = text[start:end] if end > start else text[start:]
+ types: set[str] = set()
+ for blob in _WS_SEND_STR_RE.findall(block):
+ msg_type = _extract_type_literals_from_dict_literal(blob)
+ if msg_type:
+ types.add(msg_type)
+ return sorted(types)
+
+
+def extract_server_broadcast_types(meshchat_py: Path) -> list[str]:
+ text = meshchat_py.read_text(encoding="utf-8")
+ types: set[str] = set()
+ for match in _BROADCAST_CALL_RE.finditer(text):
+ chunk = text[match.end() : match.end() + 1200]
+ type_match = _WS_TYPE_LITERAL_RE.search(chunk)
+ if type_match:
+ types.add(type_match.group(1))
+ for fn_name in ("send_config_to_websocket_clients",):
+ start = text.find(f"async def {fn_name}")
+ if start < 0:
+ continue
+ end = text.find("\n async def ", start + 1)
+ block = text[start:end] if end > start else text[start : start + 6000]
+ for type_match in _WS_TYPE_LITERAL_RE.finditer(block):
+ types.add(type_match.group(1))
+ return sorted(types)
+
+
+def load_ws_manifest(fixture_path: Path) -> dict[str, list[str]]:
+ data = json.loads(fixture_path.read_text(encoding="utf-8"))
+ for key in ("client_inbound", "client_direct_responses", "server_broadcast"):
+ data[key] = sorted(data.get(key, []))
+ return data
+
+
+def write_ws_manifest(fixture_path: Path, manifest: dict[str, list[str]]) -> None:
+ fixture_path.parent.mkdir(parents=True, exist_ok=True)
+ ordered = {
+ "client_inbound": sorted(manifest.get("client_inbound", [])),
+ "client_direct_responses": sorted(manifest.get("client_direct_responses", [])),
+ "server_broadcast": sorted(manifest.get("server_broadcast", [])),
+ }
+ fixture_path.write_text(
+ json.dumps(ordered, indent=2) + "\n",
+ encoding="utf-8",
+ )
diff --git a/tests/backend/ws_json_contract_schemas.py b/tests/backend/ws_json_contract_schemas.py
new file mode 100644
index 00000000..f9acf115
--- /dev/null
+++ b/tests/backend/ws_json_contract_schemas.py
@@ -0,0 +1,271 @@
+# SPDX-License-Identifier: 0BSD
+
+"""JSON Schema definitions for WebSocket message bodies (contract tests)."""
+
+from __future__ import annotations
+
+from tests.backend.api_json_contract_schemas import assert_matches_schema
+
+
+def _ws_type(
+ msg_type: str,
+ *,
+ required: list[str] | None = None,
+ properties: dict | None = None,
+ additional_properties: bool = True,
+) -> dict:
+ props: dict = {"type": {"type": "string", "const": msg_type}}
+ if properties:
+ props.update(properties)
+ return {
+ "type": "object",
+ "required": ["type", *(required or [])],
+ "properties": props,
+ "additionalProperties": additional_properties,
+ }
+
+
+_WS_OBJECT = {"type": "object", "additionalProperties": True}
+_WS_STRING = {"type": "string"}
+_WS_INTEGER = {"type": "integer"}
+_WS_BOOL = {"type": "boolean"}
+
+WS_MESSAGE_SCHEMAS: dict[str, dict] = {
+ "ping": _ws_type("ping"),
+ "pong": _ws_type("pong"),
+ "config.set": _ws_type("config.set", required=["config"], properties={"config": _WS_OBJECT}),
+ "config": _ws_type("config", required=["config"], properties={"config": _WS_OBJECT}),
+ "announced": _ws_type("announced"),
+ "blocked_destinations": _ws_type(
+ "blocked_destinations",
+ required=["blocked_destinations"],
+ properties={"blocked_destinations": {"type": "array", "items": _WS_OBJECT}},
+ ),
+ "keyboard_shortcuts.get": _ws_type("keyboard_shortcuts.get"),
+ "keyboard_shortcuts.set": _ws_type(
+ "keyboard_shortcuts.set",
+ required=["shortcuts"],
+ properties={"shortcuts": _WS_OBJECT},
+ ),
+ "keyboard_shortcuts.delete": _ws_type("keyboard_shortcuts.delete", required=["id"]),
+ "keyboard_shortcuts": _ws_type(
+ "keyboard_shortcuts",
+ required=["shortcuts"],
+ properties={"shortcuts": _WS_OBJECT},
+ ),
+ "nomadnet.download.cancel": _ws_type(
+ "nomadnet.download.cancel",
+ required=["download_id"],
+ properties={"download_id": _WS_INTEGER},
+ ),
+ "nomadnet.download.cancelled": _ws_type(
+ "nomadnet.download.cancelled",
+ required=["download_id"],
+ properties={"download_id": _WS_INTEGER},
+ ),
+ "nomadnet.page.archives.get": _ws_type(
+ "nomadnet.page.archives.get",
+ required=["destination_hash", "page_path"],
+ ),
+ "nomadnet.page.archives": _ws_type(
+ "nomadnet.page.archives",
+ required=["destination_hash", "page_path", "archives"],
+ properties={"archives": {"type": "array", "items": _WS_OBJECT}},
+ ),
+ "nomadnet.page.archive.load": _ws_type(
+ "nomadnet.page.archive.load",
+ required=["archive_id"],
+ ),
+ "nomadnet.page.archive.flush": _ws_type("nomadnet.page.archive.flush"),
+ "nomadnet.page.archive.add": _ws_type(
+ "nomadnet.page.archive.add",
+ required=["destination_hash", "page_path", "content"],
+ ),
+ "nomadnet.page.archive.added": _ws_type(
+ "nomadnet.page.archive.added",
+ required=["destination_hash", "page_path"],
+ ),
+ "nomadnet.file.download": _ws_type(
+ "nomadnet.file.download",
+ required=["download_id", "nomadnet_file_download"],
+ properties={"nomadnet_file_download": _WS_OBJECT},
+ ),
+ "nomadnet.page.download": _ws_type(
+ "nomadnet.page.download",
+ required=["download_id", "nomadnet_page_download"],
+ properties={"nomadnet_page_download": _WS_OBJECT},
+ ),
+ "lxmf.forwarding.rules.get": _ws_type("lxmf.forwarding.rules.get"),
+ "lxmf.forwarding.rules": _ws_type(
+ "lxmf.forwarding.rules",
+ required=["rules"],
+ properties={"rules": {"type": "array", "items": _WS_OBJECT}},
+ ),
+ "lxmf.forwarding.rule.add": _ws_type(
+ "lxmf.forwarding.rule.add",
+ required=["rule"],
+ properties={"rule": _WS_OBJECT},
+ ),
+ "lxmf.forwarding.rule.delete": _ws_type(
+ "lxmf.forwarding.rule.delete",
+ required=["rule_id"],
+ ),
+ "lxmf.forwarding.rule.toggle": _ws_type(
+ "lxmf.forwarding.rule.toggle",
+ required=["rule_id", "enabled"],
+ properties={"enabled": _WS_BOOL},
+ ),
+ "lxm.ingest_uri": _ws_type("lxm.ingest_uri", required=["uri"]),
+ "lxm.ingest_uri.result": _ws_type(
+ "lxm.ingest_uri.result",
+ required=["success"],
+ properties={"success": _WS_BOOL},
+ ),
+ "lxm.generate_paper_uri": _ws_type("lxm.generate_paper_uri"),
+ "lxm.generate_paper_uri.result": _ws_type(
+ "lxm.generate_paper_uri.result",
+ required=["success"],
+ properties={"success": _WS_BOOL},
+ ),
+ "announce": _ws_type("announce", required=["announce"], properties={"announce": _WS_OBJECT}),
+ "lxmf.delivery": _ws_type("lxmf.delivery"),
+ "lxmf_message_created": _ws_type(
+ "lxmf_message_created",
+ required=["lxmf_message"],
+ properties={"lxmf_message": _WS_OBJECT},
+ ),
+ "lxmf_message_state_updated": _ws_type(
+ "lxmf_message_state_updated",
+ required=["lxmf_message"],
+ properties={"lxmf_message": _WS_OBJECT},
+ ),
+ "lxmf_message_deleted": _ws_type("lxmf_message_deleted"),
+ "lxmf.telemetry": _ws_type("lxmf.telemetry", required=["destination_hash"]),
+ "identity_switched": _ws_type("identity_switched"),
+ "reticulum_reload_status": _ws_type("reticulum_reload_status", required=["status"]),
+ "new_voicemail": _ws_type("new_voicemail"),
+ "telephone_ringing": _ws_type("telephone_ringing"),
+ "telephone_call_established": _ws_type("telephone_call_established"),
+ "telephone_missed_call": _ws_type("telephone_missed_call"),
+ "telephone_call_ended": _ws_type("telephone_call_ended"),
+ "telephone_initiation_status": _ws_type(
+ "telephone_initiation_status",
+ required=["status"],
+ ),
+ "rrc.change": _ws_type("rrc.change"),
+ "rrc.message": _ws_type("rrc.message", required=["message"], properties={"message": _WS_OBJECT}),
+ "rrc.server.change": _ws_type("rrc.server.change"),
+ "rnsh.session.change": _ws_type("rnsh.session.change"),
+ "rnsh.output": _ws_type("rnsh.output"),
+ "rncp.transfer.progress": _ws_type("rncp.transfer.progress"),
+ "rncp.send.completed": _ws_type("rncp.send.completed"),
+ "rncp.fetch.completed": _ws_type("rncp.fetch.completed"),
+}
+
+WS_MESSAGE_SAMPLES: dict[str, dict] = {
+ "ping": {"type": "ping"},
+ "pong": {"type": "pong"},
+ "config.set": {"type": "config.set", "config": {"display_name": "Test"}},
+ "config": {"type": "config", "config": {"display_name": "Test"}},
+ "announced": {"type": "announced"},
+ "blocked_destinations": {"type": "blocked_destinations", "blocked_destinations": []},
+ "keyboard_shortcuts.get": {"type": "keyboard_shortcuts.get"},
+ "keyboard_shortcuts.set": {"type": "keyboard_shortcuts.set", "shortcuts": {}},
+ "keyboard_shortcuts.delete": {"type": "keyboard_shortcuts.delete", "id": "nav_messages"},
+ "keyboard_shortcuts": {"type": "keyboard_shortcuts", "shortcuts": {}},
+ "nomadnet.download.cancel": {"type": "nomadnet.download.cancel", "download_id": 1},
+ "nomadnet.download.cancelled": {"type": "nomadnet.download.cancelled", "download_id": 1},
+ "nomadnet.page.archives.get": {
+ "type": "nomadnet.page.archives.get",
+ "destination_hash": "aa" * 16,
+ "page_path": "/page/index.mu",
+ },
+ "nomadnet.page.archives": {
+ "type": "nomadnet.page.archives",
+ "destination_hash": "aa" * 16,
+ "page_path": "/page/index.mu",
+ "archives": [],
+ },
+ "nomadnet.page.archive.load": {"type": "nomadnet.page.archive.load", "archive_id": 1},
+ "nomadnet.page.archive.flush": {"type": "nomadnet.page.archive.flush"},
+ "nomadnet.page.archive.add": {
+ "type": "nomadnet.page.archive.add",
+ "destination_hash": "aa" * 16,
+ "page_path": "/page/index.mu",
+ "content": "body",
+ },
+ "nomadnet.page.archive.added": {
+ "type": "nomadnet.page.archive.added",
+ "destination_hash": "aa" * 16,
+ "page_path": "/page/index.mu",
+ },
+ "nomadnet.file.download": {
+ "type": "nomadnet.file.download",
+ "download_id": 1,
+ "nomadnet_file_download": {"status": "success"},
+ },
+ "nomadnet.page.download": {
+ "type": "nomadnet.page.download",
+ "download_id": 1,
+ "nomadnet_page_download": {"status": "success"},
+ },
+ "lxmf.forwarding.rules.get": {"type": "lxmf.forwarding.rules.get"},
+ "lxmf.forwarding.rules": {"type": "lxmf.forwarding.rules", "rules": []},
+ "lxmf.forwarding.rule.add": {"type": "lxmf.forwarding.rule.add", "rule": {"id": 1}},
+ "lxmf.forwarding.rule.delete": {"type": "lxmf.forwarding.rule.delete", "rule_id": 1},
+ "lxmf.forwarding.rule.toggle": {
+ "type": "lxmf.forwarding.rule.toggle",
+ "rule_id": 1,
+ "enabled": True,
+ },
+ "lxm.ingest_uri": {"type": "lxm.ingest_uri", "uri": "meshchatx://map"},
+ "lxm.ingest_uri.result": {"type": "lxm.ingest_uri.result", "success": True},
+ "lxm.generate_paper_uri": {"type": "lxm.generate_paper_uri"},
+ "lxm.generate_paper_uri.result": {
+ "type": "lxm.generate_paper_uri.result",
+ "success": True,
+ "uri": "lxm://paper",
+ },
+ "announce": {"type": "announce", "announce": {"aspect": "lxmf.delivery"}},
+ "lxmf.delivery": {"type": "lxmf.delivery"},
+ "lxmf_message_created": {
+ "type": "lxmf_message_created",
+ "lxmf_message": {"hash": "cc" * 16},
+ },
+ "lxmf_message_state_updated": {
+ "type": "lxmf_message_state_updated",
+ "lxmf_message": {"hash": "cc" * 16, "state": "sent"},
+ },
+ "lxmf_message_deleted": {"type": "lxmf_message_deleted", "hash": "cc" * 16},
+ "lxmf.telemetry": {"type": "lxmf.telemetry", "destination_hash": "aa" * 16},
+ "identity_switched": {"type": "identity_switched"},
+ "reticulum_reload_status": {"type": "reticulum_reload_status", "status": "ok"},
+ "new_voicemail": {"type": "new_voicemail", "id": 1},
+ "telephone_ringing": {"type": "telephone_ringing", "remote_identity_hash": "aa" * 16},
+ "telephone_call_established": {"type": "telephone_call_established"},
+ "telephone_missed_call": {"type": "telephone_missed_call"},
+ "telephone_call_ended": {"type": "telephone_call_ended"},
+ "telephone_initiation_status": {"type": "telephone_initiation_status", "status": "ringing"},
+ "rrc.change": {"type": "rrc.change"},
+ "rrc.message": {"type": "rrc.message", "message": {"kind": "msg"}},
+ "rrc.server.change": {"type": "rrc.server.change"},
+ "rnsh.session.change": {"type": "rnsh.session.change"},
+ "rnsh.output": {"type": "rnsh.output", "data": "ok"},
+ "rncp.transfer.progress": {"type": "rncp.transfer.progress", "percent": 50},
+ "rncp.send.completed": {"type": "rncp.send.completed"},
+ "rncp.fetch.completed": {"type": "rncp.fetch.completed"},
+}
+
+
+def all_ws_message_types() -> set[str]:
+ return set(WS_MESSAGE_SCHEMAS)
+
+
+def assert_ws_message_matches_schema(message: dict) -> None:
+ msg_type = message.get("type")
+ if not isinstance(msg_type, str):
+ raise AssertionError("WebSocket message missing string type")
+ schema = WS_MESSAGE_SCHEMAS.get(msg_type)
+ if schema is None:
+ raise AssertionError(f"No WebSocket schema registered for type {msg_type!r}")
+ assert_matches_schema(message, schema)
diff --git a/tests/frontend/__snapshots__/uiSnapshots.test.js.snap b/tests/frontend/__snapshots__/uiSnapshots.test.js.snap
new file mode 100644
index 00000000..7d468f6a
--- /dev/null
+++ b/tests/frontend/__snapshots__/uiSnapshots.test.js.snap
@@ -0,0 +1,19 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`UI snapshot regression > ConfirmDialog.vue > visible confirm dialog 1`] = `"<div="" class="fixed inset-0 z-9999 flex items-center justify-center p-4 confirm-dialog-enter-from confirm-dialog-enter-active"><div="" class="fixed inset-0 bg-black/50 backdrop-blur-xs shadow-2xl"></div><div="" class="relative w-full sm:w-auto sm:min-w-[400px] sm:max-w-md bg-white dark:bg-zinc-900 sm:rounded-3xl rounded-3xl shadow-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden transform transition-all"><div="" class="p-8"><div="" class="flex items-start mb-6"><div="" class="shrink-0 flex items-center justify-center w-12 h-12 rounded-2xl bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 mr-4"><span="" class="mdi-snapshot w-6 h-6" data-icon="alert-circle"></span></div><div="" class="flex-1 min-w-0"><h3="" class="text-xl font-black text-gray-900 dark:text-white mb-2">Confirm Action</h3><p="" class="text-gray-600 dark:text-zinc-300 whitespace-pre-wrap leading-relaxed">Delete this item?</p></div></div><div="" class="flex flex-col sm:flex-row gap-3 sm:justify-end mt-8"><button="" type="button" class="px-6 py-3 text-sm font-bold text-gray-700 dark:text-zinc-300 bg-gray-100 dark:bg-zinc-800 rounded-xl hover:bg-gray-200 dark:hover:bg-zinc-700 transition-all active:scale-95"> Cancel </button><button="" type="button" class="px-6 py-3 text-sm font-bold text-white bg-red-600 hover:bg-red-700 rounded-xl shadow-lg shadow-red-600/20 transition-all active:scale-95"> Confirm </button></div></div></div></div>"`;
+
+exports[`UI snapshot regression > FormLabel.vue > default label 1`] = `"<label class="block text-sm font-medium text-gray-900 dark:text-zinc-100">Display name</label>"`;
+
+exports[`UI snapshot regression > FormSubLabel.vue > default sub-label 1`] = `"<div class="text-xs text-gray-600 dark:text-zinc-300">Optional helper text</div>"`;
+
+exports[`UI snapshot regression > IconButton.vue > icon button 1`] = `"<button type="button" class="text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-100 hover:bg-gray-100 dark:hover:bg-zinc-800 p-2 rounded-full w-9 h-9 flex items-center justify-center shrink-0 transition-all duration-200"><span class="icon">+</span></button>"`;
+
+exports[`UI snapshot regression > SidebarLink.vue > sidebar link 1`] = `"<a class="router-link-stub" href="#" custom=""><a href="#" type="button" class="hover:bg-gray-100 dark:hover:bg-zinc-700 w-full text-gray-800 dark:text-zinc-200 group flex gap-x-3 rounded-r-full p-2 mr-2 text-sm leading-6 font-semibold focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:focus-visible:outline-zinc-500 overflow-hidden"><span class="my-auto shrink-0"><span class="icon-slot">M</span></span><span class="my-auto flex w-full truncate transition-all duration-300">Messages</span></a></a>"`;
+
+exports[`UI snapshot regression > Toast.vue > empty state 1`] = `"<div="" class="fixed max-sm:bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"><div="" class="snapshot-transition-group" name="toast"></div></div>"`;
+
+exports[`UI snapshot regression > Toast.vue > error toast 1`] = `"<div="" class="fixed max-sm:bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"><div="" class="snapshot-transition-group" name="toast"></div></div>"`;
+
+exports[`UI snapshot regression > Toast.vue > loading toast 1`] = `"<div="" class="fixed max-sm:bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"><div="" class="snapshot-transition-group" name="toast"></div></div>"`;
+
+exports[`UI snapshot regression > Toast.vue > success toast 1`] = `"<div="" class="fixed max-sm:bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"><div="" class="snapshot-transition-group" name="toast"></div></div>"`;
diff --git a/tests/frontend/snapshotTestUtils.js b/tests/frontend/snapshotTestUtils.js
new file mode 100644
index 00000000..4131cf2a
--- /dev/null
+++ b/tests/frontend/snapshotTestUtils.js
@@ -0,0 +1,49 @@
+import { mount } from "@vue/test-utils";
+
+export const snapshotMaterialDesignIcon = {
+ name: "MaterialDesignIcon",
+ template: '<span class="mdi-snapshot" :data-icon="iconName"></span>',
+ props: ["iconName"],
+};
+
+export const snapshotTransitionGroup = {
+ template: "<div class=\"snapshot-transition-group\"><slot /></div>",
+};
+
+export function snapshotI18nMock() {
+ return (key) => key;
+}
+
+export function normalizeSnapshotHtml(html) {
+ return String(html)
+ .replace(/\sdata-v-[a-f0-9]+/g, "")
+ .replace(/\s+id="[^"]*"/g, "")
+ .replace(/\s+style="[^"]*"/g, "")
+ .replace(/<!--[\s\S]*?-->/g, "")
+ .replace(/>\s+</g, "><")
+ .trim();
+}
+
+export function mountSnapshot(component, options = {}) {
+ const { props, slots, global = {} } = options;
+ return mount(component, {
+ props,
+ slots,
+ global: {
+ mocks: {
+ $t: snapshotI18nMock(),
+ ...(global.mocks || {}),
+ },
+ stubs: {
+ MaterialDesignIcon: snapshotMaterialDesignIcon,
+ TransitionGroup: snapshotTransitionGroup,
+ ...(global.stubs || {}),
+ },
+ ...global,
+ },
+ });
+}
+
+export function expectHtmlSnapshot(wrapper) {
+ expect(normalizeSnapshotHtml(wrapper.html())).toMatchSnapshot();
+}
diff --git a/tests/frontend/uiSnapshots.test.js b/tests/frontend/uiSnapshots.test.js
new file mode 100644
index 00000000..0f043523
--- /dev/null
+++ b/tests/frontend/uiSnapshots.test.js
@@ -0,0 +1,118 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import Toast from "@/components/Toast.vue";
+import ConfirmDialog from "@/components/ConfirmDialog.vue";
+import FormLabel from "@/components/forms/FormLabel.vue";
+import FormSubLabel from "@/components/forms/FormSubLabel.vue";
+import IconButton from "@/components/IconButton.vue";
+import SidebarLink from "@/components/SidebarLink.vue";
+import GlobalEmitter from "@/js/GlobalEmitter";
+import { expectHtmlSnapshot, mountSnapshot } from "./snapshotTestUtils.js";
+
+vi.mock("@/js/GlobalEmitter", () => ({
+ default: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
+}));
+
+const RouterLinkStub = {
+ name: "RouterLinkStub",
+ props: ["to"],
+ template: '<a class="router-link-stub" href="#"><slot :href="\'#\'" :navigate="() => {}" :isActive="false"/></a>',
+};
+
+describe("UI snapshot regression", () => {
+ describe("Toast.vue", () => {
+ let wrapper;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ wrapper = mountSnapshot(Toast);
+ });
+
+ afterEach(() => {
+ wrapper?.unmount();
+ vi.useRealTimers();
+ });
+
+ it("empty state", () => {
+ expectHtmlSnapshot(wrapper);
+ });
+
+ it("success toast", async () => {
+ GlobalEmitter.emit("toast", { message: "Saved", type: "success", duration: 0 });
+ await wrapper.vm.$nextTick();
+ expectHtmlSnapshot(wrapper);
+ });
+
+ it("error toast", async () => {
+ GlobalEmitter.emit("toast", { message: "Failed", type: "error", duration: 0 });
+ await wrapper.vm.$nextTick();
+ expectHtmlSnapshot(wrapper);
+ });
+
+ it("loading toast", async () => {
+ GlobalEmitter.emit("toast", {
+ message: "Working",
+ type: "loading",
+ duration: 0,
+ key: "job-1",
+ });
+ await wrapper.vm.$nextTick();
+ expectHtmlSnapshot(wrapper);
+ });
+ });
+
+ describe("ConfirmDialog.vue", () => {
+ it("visible confirm dialog", async () => {
+ const wrapper = mountSnapshot(ConfirmDialog);
+ const showFn = vi.mocked(GlobalEmitter.on).mock.calls.find((c) => c[0] === "confirm")?.[1];
+ showFn?.({ message: "Delete this item?", resolve: vi.fn() });
+ await wrapper.vm.$nextTick();
+ expectHtmlSnapshot(wrapper);
+ wrapper.unmount();
+ });
+ });
+
+ describe("FormLabel.vue", () => {
+ it("default label", () => {
+ const wrapper = mountSnapshot(FormLabel, {
+ slots: { default: "Display name" },
+ });
+ expectHtmlSnapshot(wrapper);
+ wrapper.unmount();
+ });
+ });
+
+ describe("FormSubLabel.vue", () => {
+ it("default sub-label", () => {
+ const wrapper = mountSnapshot(FormSubLabel, {
+ slots: { default: "Optional helper text" },
+ });
+ expectHtmlSnapshot(wrapper);
+ wrapper.unmount();
+ });
+ });
+
+ describe("IconButton.vue", () => {
+ it("icon button", () => {
+ const wrapper = mountSnapshot(IconButton, {
+ slots: { default: '<span class="icon">+</span>' },
+ });
+ expectHtmlSnapshot(wrapper);
+ wrapper.unmount();
+ });
+ });
+
+ describe("SidebarLink.vue", () => {
+ it("sidebar link", () => {
+ const wrapper = mountSnapshot(SidebarLink, {
+ props: { to: { name: "messages" } },
+ slots: {
+ icon: '<span class="icon-slot">M</span>',
+ text: "Messages",
+ },
+ global: { stubs: { RouterLink: RouterLinkStub } },
+ });
+ expectHtmlSnapshot(wrapper);
+ wrapper.unmount();
+ });
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────